From 74a89f2d3d1cfe4c809f4dd4fc7bf033724db122 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Sat, 12 Apr 2025 16:21:06 +0300 Subject: [PATCH] React 19, react-aria-components 1.7, framer-motion 12, tanstack query 5.72 --- app/common/package.json | 22 +- app/gui/package.json | 47 +- app/gui/src/App.vue | 8 +- app/gui/src/ReactRoot.tsx | 43 +- app/gui/src/dashboard/App.tsx | 10 +- app/gui/src/dashboard/components/Activity.tsx | 11 +- .../components/AriaComponents/Alert/Alert.tsx | 15 +- .../AriaComponents/Button/Button.tsx | 443 +- .../AriaComponents/Button/ButtonGroup.tsx | 11 +- .../AriaComponents/Button/CopyButton.tsx | 13 +- .../components/AriaComponents/Button/types.ts | 3 +- .../AriaComponents/Checkbox/Checkbox.tsx | 25 +- .../AriaComponents/Checkbox/CheckboxGroup.tsx | 163 +- .../AriaComponents/CopyBlock/CopyBlock.tsx | 6 +- .../AriaComponents/Dialog/Dialog.tsx | 8 +- .../AriaComponents/Dialog/DialogDismiss.tsx | 3 +- .../AriaComponents/Dialog/Popover.tsx | 2 +- .../AriaComponents/Dialog/utilities.ts | 2 +- .../components/AriaComponents/Form/Form.tsx | 37 +- .../AriaComponents/Form/components/Field.tsx | 18 +- .../Form/components/FormProvider.tsx | 29 +- .../AriaComponents/Form/components/Submit.tsx | 3 +- .../components/AriaComponents/Form/types.ts | 9 +- .../Inputs/ComboBox/ComboBox.tsx | 15 +- .../Inputs/DatePicker/DatePicker.tsx | 17 +- .../Inputs/Dropdown/Dropdown.tsx | 20 +- .../AriaComponents/Inputs/Input/Input.tsx | 20 +- .../Inputs/MultiSelector/MultiSelector.tsx | 12 +- .../ResizableContentEditableInput.tsx | 22 +- .../Inputs/Selector/Selector.tsx | 17 +- .../Inputs/Selector/SelectorOption.tsx | 86 +- .../Inputs/TimeField/TimeField.tsx | 17 +- .../AriaComponents/Switch/Switch.tsx | 17 +- .../components/AriaComponents/Text/Text.tsx | 221 +- .../VisualTooltip/useVisualTooltip.tsx | 14 +- .../components/AriaComponents/types.ts | 9 +- app/gui/src/dashboard/components/Await.tsx | 84 +- .../src/dashboard/components/ColorPicker.tsx | 9 +- .../src/dashboard/components/EditableSpan.tsx | 6 +- .../dashboard/components/JSONSchemaInput.tsx | 5 +- app/gui/src/dashboard/components/Link.tsx | 10 +- .../MarkdownViewer/MarkdownViewer.tsx | 11 +- .../MarkdownViewer/defaultRenderer.ts | 2 +- .../components/Paywall/PaywallAlert.tsx | 5 +- app/gui/src/dashboard/components/Result.tsx | 6 +- .../dashboard/components/SelectionBrush.tsx | 2 +- .../components/__tests__/Await.test.tsx | 62 - .../dashboard/components/dashboard/column.ts | 6 +- .../dashboard/components/styled/FocusArea.tsx | 4 +- .../components/styled/RadioGroup.tsx | 9 +- app/gui/src/dashboard/hooks/autoFocusHooks.ts | 2 +- .../dashboard/hooks/backendBatchedHooks.ts | 24 +- app/gui/src/dashboard/hooks/backendHooks.ts | 6 +- app/gui/src/dashboard/hooks/copyHooks.ts | 79 +- .../dashboard/hooks/debounceCallbackHooks.ts | 12 +- app/gui/src/dashboard/hooks/debugHooks.ts | 2 + .../src/dashboard/hooks/eventListenerHooks.ts | 6 +- app/gui/src/dashboard/hooks/projectHooks.ts | 6 +- app/gui/src/dashboard/hooks/scrollHooks.ts | 6 +- app/gui/src/dashboard/hooks/storeHooks.ts | 23 +- .../dashboard/layouts/AssetContextMenu.tsx | 8 +- .../layouts/AssetPanel/AssetPanel.tsx | 49 +- .../src/dashboard/layouts/AssetSearchBar.tsx | 3 +- app/gui/src/dashboard/layouts/AssetsTable.tsx | 2 +- .../layouts/AssetsTableContextMenu.tsx | 4 +- .../layouts/Settings/SetupTwoFaForm.tsx | 7 +- app/gui/src/dashboard/modals/DragModal.tsx | 2 +- .../dashboard/pages/authentication/Login.tsx | 2 +- .../src/dashboard/providers/AuthProvider.tsx | 5 +- .../providers/LocalStorageProvider.tsx | 58 +- .../src/dashboard/providers/ModalProvider.tsx | 2 +- .../dashboard/providers/ProjectsProvider.tsx | 4 +- app/gui/src/dashboard/utilities/Debug.tsx | 3 +- .../src/dashboard/utilities/LocalStorage.ts | 32 +- app/gui/src/dashboard/utilities/mergeRefs.ts | 10 +- app/gui/src/dashboard/utilities/motion.ts | 75 - app/gui/src/dashboard/utilities/react.ts | 24 +- app/gui/src/entrypoint.ts | 2 +- .../components/MarkdownEditor.vue | 2 + .../MarkdownEditor/MarkdownEditorImpl.vue | 2 + app/gui/vite.config.ts | 5 +- package.json | 4 +- pnpm-lock.yaml | 4650 ++++++----------- 83 files changed, 2442 insertions(+), 4318 deletions(-) delete mode 100644 app/gui/src/dashboard/components/__tests__/Await.test.tsx delete mode 100644 app/gui/src/dashboard/utilities/motion.ts diff --git a/app/common/package.json b/app/common/package.json index 966d59468ac1..aae512f87441 100644 --- a/app/common/package.json +++ b/app/common/package.json @@ -22,17 +22,25 @@ "lint": "eslint ./src --cache --max-warnings=0" }, "peerDependencies": { - "@tanstack/query-core": "5.59.20", - "@tanstack/vue-query": "5.59.20", - "zod": "^3.23.0" + "@tanstack/query-core": "5.72.2", + "@tanstack/vue-query": "5.73.0", + "zod": "^3.24.1", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "@internationalized/date": "3.7.0" }, "dependencies": { - "@internationalized/date": "3.7.0", - "@tanstack/query-persist-client-core": "5.59.20", - "@tanstack/vue-query": "5.59.20", + "@tanstack/query-persist-client-core": "5.73.1", "@types/node": "^20.11.21", "lib0": "^0.2.99", - "react": "^18.3.1", "vitest": "3.0.5" + }, + "devDependencies": { + "@internationalized/date": "3.7.0", + "@types/react": "^19.1.1", + "@types/react-dom": "^19.1.2", + "@tanstack/vue-query": "5.73.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" } } diff --git a/app/gui/package.json b/app/gui/package.json index 935a91a2f848..abcea9248e4a 100644 --- a/app/gui/package.json +++ b/app/gui/package.json @@ -68,33 +68,32 @@ "@lexical/markdown": "^0.21.0", "@lezer/common": "^1.2.3", "@lezer/highlight": "^1.2.1", - "@monaco-editor/react": "4.6.0", + "@monaco-editor/react": "4.7.0", "@noble/hashes": "^1.6.1", "@react-aria/collections": "3.0.0-alpha.7", "@react-aria/interactions": "3.23.0", - "@sentry/vue": "^7.120.2", - "@sentry/vite-plugin": "^2.22.7", - "@stripe/react-stripe-js": "^2.9.0", - "@stripe/stripe-js": "^3.5.0", - "@tanstack/react-query": "5.59.20", - "@tanstack/vue-query": "5.59.20", + "@sentry/vue": "9.12.0", + "@sentry/vite-plugin": "3.3.1", + "@stripe/react-stripe-js": "3.6.0", + "@stripe/stripe-js": "7.0.0", + "@tanstack/react-query": "5.72.2", + "@tanstack/vue-query": "5.73.0", "@vueuse/core": "^13.0.0", "@vueuse/gesture": "^2.0.0", "ag-grid-community": "^32.3.3", "ag-grid-enterprise": "^32.3.3", "ajv": "^8.17.1", "amazon-cognito-identity-js": "6.3.6", - "babel-plugin-react-compiler": "19.0.0-beta-63e3235-20250105", - "clsx": "^2.1.1", + "babel-plugin-react-compiler": "19.0.0-beta-e993439-20250405", "codemirror": "^6.0.1", "culori": "^3.3.0", "dotenv": "^16.4.7", "enso-common": "workspace:*", "events": "^3.3.0", - "framer-motion": "11.3.0", + "framer-motion": "12.6.5", "hash-sum": "^2.0.0", "idb-keyval": "^6.2.1", - "input-otp": "1.2.4", + "input-otp": "1.4.2", "install": "^0.13.0", "is-network-error": "^1.1.0", "lexical": "^0.21.0", @@ -107,15 +106,14 @@ "papaparse": "^5.4.1", "postcss-inline-svg": "^6.0.0", "postcss-nesting": "^12.1.5", - "qrcode.react": "3.1.0", - "react": "^18.3.1", + "qrcode.react": "4.2.0", + "react": "^19.1.0", "react-aria": "3.37.0", "react-aria-components": "1.6.0", - "react-compiler-runtime": "19.0.0-beta-decd7b8-20250118", - "react-dom": "^18.3.1", - "react-error-boundary": "4.0.13", + "react-keyed-flatten-children": "5.0.0", + "react-dom": "^19.1.0", + "react-error-boundary": "5.0.0", "react-hook-form": "^7.54.2", - "react-keyed-flatten-children": "3.0.2", "react-stately": "3.35.0", "react-toastify": "^9.1.3", "sucrase": "^3.35.0", @@ -124,7 +122,7 @@ "tiny-invariant": "^1.3.3", "ts-results": "^3.3.0", "validator": "^13.12.0", - "veaury": "=2.4.4", + "veaury": "2.6.2", "motion-v": "^1.0.0-beta.1", "vue": "^3.5.13", "vue-router": "^4.5.0", @@ -135,7 +133,7 @@ "ydoc-shared": "workspace:*", "yjs": "^13.6.21", "zod": "^3.24.1", - "zustand": "^4.5.5" + "zustand": "5.0.3" }, "devDependencies": { "@babel/plugin-syntax-import-attributes": "^7.26.0", @@ -155,11 +153,10 @@ "@storybook/test": "8.5.0", "@storybook/vue3": "8.5.0", "@storybook/vue3-vite": "8.5.0", - "@tanstack/react-query-devtools": "5.59.20", + "@tanstack/react-query-devtools": "5.72.2", "@testing-library/jest-dom": "6.6.3", - "@testing-library/react": "16.0.1", - "@testing-library/react-hooks": "8.0.1", - "@testing-library/user-event": "14.5.2", + "@testing-library/react": "16.3.0", + "@testing-library/user-event": "14.6.1", "@tsconfig/node20": "^20.1.4", "@types/css.escape": "^1.5.2", "@types/culori": "^2.1.1", @@ -169,8 +166,8 @@ "@types/mapbox-gl": "^3.4.1", "@types/node": "^22.10.4", "@types/papaparse": "^5.3.15", - "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", + "@types/react": "^19.1.1", + "@types/react-dom": "^19.1.2", "@types/shuffle-seed": "^1.1.3", "@types/tar": "^6.1.13", "@types/validator": "^13.12.2", diff --git a/app/gui/src/App.vue b/app/gui/src/App.vue index 04396e12aa40..f14248e10613 100644 --- a/app/gui/src/App.vue +++ b/app/gui/src/App.vue @@ -16,10 +16,15 @@ import { registerAutoBlurHandler, registerGlobalBlurHandler } from '@/util/autoB import { baseConfig, configValue, mergeConfig, type ApplicationConfigValue } from '@/util/config' import { urlParams } from '@/util/urlParams' import { useQueryClient } from '@tanstack/vue-query' -import { applyPureReactInVue } from 'veaury' +import { createRoot } from 'react-dom/client' +import { applyPureReactInVue, setVeauryOptions } from 'veaury' import { computed, onMounted } from 'vue' import { ComponentProps } from 'vue-component-type-helpers' +setVeauryOptions({ react: { createRoot } }) + +const ReactRootInVue = applyPureReactInVue(ReactRoot) + const { projectViewOnly, onAuthenticated } = defineProps<{ // Used in Project View integration tests. Once both test projects will be merged, this should be // removed @@ -43,7 +48,6 @@ const appConfig = computed(() => ) const appConfigValue = computed((): ApplicationConfigValue => configValue(appConfig.value)) -const ReactRootWrapper = applyPureReactInVue(ReactRoot) const queryClient = useQueryClient() provideKeyboard() diff --git a/app/gui/src/ReactRoot.tsx b/app/gui/src/ReactRoot.tsx index c1e654edc84f..d64a7243feea 100644 --- a/app/gui/src/ReactRoot.tsx +++ b/app/gui/src/ReactRoot.tsx @@ -13,13 +13,13 @@ import HttpClient from '#/utilities/HttpClient' import { QueryClientProvider } from '@tanstack/react-query' import { QueryClient } from '@tanstack/vue-query' import { IS_DEV_MODE, isOnElectron, isOnLinux } from 'enso-common/src/detect' -import { PropsWithChildren, StrictMode } from 'react' +import { PropsWithChildren } from 'react' import invariant from 'tiny-invariant' interface ReactRootProps { queryClient: QueryClient - classSet: Map onAuthenticated: (accessToken: string | null) => void + classSet?: Map } function generateSessionID() { @@ -57,30 +57,29 @@ export default function ReactRoot(props: PropsWithChildren) { const isCloudBuild = $config.CLOUD_BUILD === 'true' return ( - - + + - - }> - - - - + }> + + + + {children} - - - - + + + + - - + - - + + ) } diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index af5d2f79d55f..19a36c90baed 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -148,11 +148,11 @@ export default function App(props: React.PropsWithChildren) { projectManagerInstance: new ProjectManager(config.projectManagerUrl, rootDirectory), projectManagerRootDirectory: rootDirectory, } - } else { - return { - projectManagerInstance: null, - projectManagerRootDirectory: null, - } + } + + return { + projectManagerInstance: null, + projectManagerRootDirectory: null, } }, }) diff --git a/app/gui/src/dashboard/components/Activity.tsx b/app/gui/src/dashboard/components/Activity.tsx index 478cdc886b44..25b23a5b0a34 100644 --- a/app/gui/src/dashboard/components/Activity.tsx +++ b/app/gui/src/dashboard/components/Activity.tsx @@ -4,8 +4,7 @@ * This component is used to suspend the rendering of a subtree until a promise is resolved. */ import { unsafeWriteValue } from '#/utilities/write' -import { startTransition, Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react' -import { useAwait } from './Await' +import { startTransition, Suspense, use, useEffect, useLayoutEffect, useRef, useState } from 'react' /** * Props for {@link Activity} @@ -88,8 +87,10 @@ interface ActivityInnerProps { function ActivityInner(props: ActivityInnerProps) { const { promise, children } = props - // Suspend the subtree - useAwait(promise) + if (promise != null) { + // Suspend the subtree + use(promise) + } return children } @@ -98,7 +99,7 @@ function ActivityInner(props: ActivityInnerProps) { * Props for {@link UnhideSuspendedTree} */ interface UnhideSuspendedTreeProps { - readonly contentRef: React.RefObject + readonly contentRef: React.RefObject } /** diff --git a/app/gui/src/dashboard/components/AriaComponents/Alert/Alert.tsx b/app/gui/src/dashboard/components/AriaComponents/Alert/Alert.tsx index 144856431f0a..b0cc77ade0de 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Alert/Alert.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Alert/Alert.tsx @@ -1,10 +1,10 @@ /** @file Alert component. */ -import { type ForwardedRef, type HTMLAttributes, type PropsWithChildren } from 'react' +import { type HTMLAttributes, type PropsWithChildren } from 'react' -import { forwardRef } from '#/utilities/react' import { tv, type VariantProps } from '#/utilities/tailwindVariants' import { Icon } from '../../Icon' -import type { IconProp } from '../types' +import type { IconProp, PropsWithRef } from '../types' + // eslint-disable-next-line react-refresh/only-export-components export const ALERT_STYLES = tv({ base: 'flex items-stretch gap-2', @@ -51,16 +51,14 @@ export const ALERT_STYLES = tv({ export interface AlertProps extends PropsWithChildren, VariantProps, + PropsWithRef, HTMLAttributes { /** The icon to display in the Alert */ readonly icon?: IconProp | null | undefined } /** Alert component. */ -export const Alert = forwardRef(function Alert( - props: AlertProps, - ref: ForwardedRef, -) { +export function Alert(props: AlertProps) { const { children, className, @@ -72,6 +70,7 @@ export const Alert = forwardRef(function Alert variants = ALERT_STYLES, tabIndex: rawTabIndex, role: rawRole, + ref, ...containerProps } = props @@ -98,4 +97,4 @@ export const Alert = forwardRef(function Alert
{children}
) -}) +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx index 32d5b1adf9fa..fa79bad30210 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx @@ -4,7 +4,7 @@ import { useLayoutEffect, useRef, useState, - type ForwardedRef, + useTransition, type ReactElement, type ReactNode, } from 'react' @@ -15,7 +15,6 @@ import { useVisualTooltip } from '#/components/AriaComponents/VisualTooltip' import { Icon as IconComponent } from '#/components/Icon' import { StatelessSpinner } from '#/components/StatelessSpinner' import { useEventCallback } from '#/hooks/eventCallbackHooks' -import { forwardRef } from '#/utilities/react' import { useContextProps } from '../../hooks/useContextProps' import { useDialogContext } from '../Dialog' import { ButtonGroup, ButtonGroupJoin } from './ButtonGroup' @@ -32,258 +31,244 @@ const ICON_LOADER_DELAY = 150 /** A button allows a user to perform an action, with mouse, touch, and keyboard interactions. */ // Manually casting types to make TS infer the final type correctly (e.g. RenderProps in icon) -// eslint-disable-next-line no-restricted-syntax -export const Button = memo( - forwardRef(function Button( - propsReplacement: ButtonProps, - refReplacement: ForwardedRef, - ) { - // @ts-expect-error ts errors are expected here because we are merging props with different types - // eslint-disable-next-line prefer-const - let [props, ref] = useContextProps(propsReplacement, refReplacement, ButtonContext) - props = useMergedButtonStyles(props) - - const dialogContext = useDialogContext() - - const { - className, - contentClassName, - children, - variant, - icon, - loading, - isLoading, - isActive, - showIconOnHover, - iconPosition, - size, - fullWidth, - rounded, - tooltip, - tooltipPlacement, - testId, - loaderPosition = 'full', - extraClickZone: extraClickZoneProp, - onPress = () => {}, - variants = BUTTON_STYLES, - addonStart, - addonEnd, - hideLoader = false, - ...ariaProps - } = props - - const { position, isJoined } = useJoinedButtonPrivateContext() - - const [implicitlyLoading, setImplicitlyLoading] = useState(false) - - const contentRef = useRef(null) - const loaderRef = useRef(null) - - const isLink = ariaProps.href != null - - const Tag = isLink ? aria.Link : aria.Button - - const goodDefaults = { - ...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }), - 'data-testid': testId, - } - const isIconOnly = (children == null || children === '' || children === false) && icon != null +export function Button(propsReplacement: ButtonProps) { + const refReplacement = propsReplacement.ref + // @ts-expect-error ts errors are expected here because we are merging props with different types + // eslint-disable-next-line prefer-const + let [props, ref] = useContextProps(propsReplacement, refReplacement, ButtonContext) + props = useMergedButtonStyles(props) - const shouldShowTooltip = (() => { - if (tooltip === false) { - return false - } else if (isIconOnly) { - return true - } else { - return tooltip != null - } - })() + const dialogContext = useDialogContext() - const tooltipElement = shouldShowTooltip ? (tooltip ?? ariaProps['aria-label']) : null + const { + className, + contentClassName, + children, + variant, + icon, + loading, + isLoading, + isActive, + showIconOnHover, + iconPosition, + size, + fullWidth, + rounded, + tooltip, + tooltipPlacement, + testId, + loaderPosition = 'full', + extraClickZone: extraClickZoneProp, + onPress = () => {}, + variants = BUTTON_STYLES, + addonStart, + addonEnd, + hideLoader = false, + ...ariaProps + } = props - const isLoadingFinal = (() => { - if (typeof loading === 'boolean') { - return loading - } + const { position, isJoined } = useJoinedButtonPrivateContext() - if (typeof isLoading === 'boolean') { - return isLoading - } + const [implicitlyLoading, setImplicitlyLoading] = useTransition() - return implicitlyLoading - })() + const contentRef = useRef(null) + const loaderRef = useRef(null) - const isDisabled = props.isDisabled ?? isLoadingFinal - const shouldUseVisualTooltip = shouldShowTooltip && isDisabled - const extraClickZone = extraClickZoneProp ?? variant === 'icon' + const isLink = ariaProps.href != null - useLayoutEffect(() => { - const delay = ICON_LOADER_DELAY + const Tag = isLink ? aria.Link : aria.Button - if (isLoadingFinal) { - const loaderAnimation = loaderRef.current?.animate( - [{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }], - { duration: delay, easing: 'linear', delay: 0, fill: 'forwards' }, - ) - const contentAnimation = - loaderPosition !== 'full' ? null : ( - contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], { - duration: 0, - easing: 'linear', - delay, - fill: 'forwards', - }) - ) - - return () => { - loaderAnimation?.cancel() - contentAnimation?.cancel() - } - } else { - return () => {} - } - }, [isLoadingFinal, loaderPosition]) + const goodDefaults = { + ...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }), + 'data-testid': testId, + } - const handlePress = useEventCallback((event: aria.PressEvent): void => { - if (!isDisabled) { - const result = onPress?.(event) + const isIconOnly = (children == null || children === '' || children === false) && icon != null - if (result instanceof Promise) { - setImplicitlyLoading(true) + const shouldShowTooltip = (() => { + if (tooltip === false) { + return false + } else if (isIconOnly) { + return true + } else { + return tooltip != null + } + })() + + const tooltipElement = shouldShowTooltip ? (tooltip ?? ariaProps['aria-label']) : null + + const isLoadingFinal = (() => { + if (typeof loading === 'boolean') { + return loading + } - void result.finally(() => { - setImplicitlyLoading(false) + if (typeof isLoading === 'boolean') { + return isLoading + } + + return implicitlyLoading + })() + + const isDisabled = props.isDisabled ?? isLoadingFinal + const shouldUseVisualTooltip = shouldShowTooltip && isDisabled + const extraClickZone = extraClickZoneProp ?? variant === 'icon' + + useLayoutEffect(() => { + const delay = ICON_LOADER_DELAY + + if (isLoadingFinal) { + const loaderAnimation = loaderRef.current?.animate( + [{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }], + { duration: delay, easing: 'linear', delay: 0, fill: 'forwards' }, + ) + const contentAnimation = + loaderPosition !== 'full' ? null : ( + contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], { + duration: 0, + easing: 'linear', + delay, + fill: 'forwards', }) - } + ) - if (dialogContext != null && 'formMethod' in props && props.formMethod === 'dialog') { - dialogContext.close() - } + return () => { + loaderAnimation?.cancel() + contentAnimation?.cancel() } - }) - - const styles = variants({ - isDisabled, - isActive, - loading: isLoadingFinal, - fullWidth, - size, - rounded, - variant, - iconPosition, - showIconOnHover, - extraClickZone, - iconOnly: isIconOnly, - isJoined, - position, - }) - - const { tooltip: visualTooltip, targetProps } = useVisualTooltip({ - targetRef: contentRef, - children: tooltipElement, - isDisabled: !shouldUseVisualTooltip, - overlayPositionProps: { placement: tooltipPlacement ?? 'top' }, - }) - - const shouldDisplayBorder = isJoined && (position === 'first' || position === 'middle') - - const button = ( - {} + } + }, [isLoadingFinal, loaderPosition]) + + const handlePress = useEventCallback((event: aria.PressEvent): void => { + if (!isDisabled) { + const result = onPress?.(event) + + if (result instanceof Promise) { + setImplicitlyLoading(async () => { + await result + }) + } + + if (dialogContext != null && 'formMethod' in props && props.formMethod === 'dialog') { + dialogContext.close() + } + } + }) + + const styles = variants({ + isDisabled, + isActive, + loading: isLoadingFinal, + fullWidth, + size, + rounded, + variant, + iconPosition, + showIconOnHover, + extraClickZone, + iconOnly: isIconOnly, + isJoined, + position, + }) + + const { tooltip: visualTooltip, targetProps } = useVisualTooltip({ + targetRef: contentRef, + children: tooltipElement, + isDisabled: !shouldUseVisualTooltip, + overlayPositionProps: { placement: tooltipPlacement ?? 'top' }, + }) + + const shouldDisplayBorder = isJoined && (position === 'first' || position === 'middle') + + const button = ( + ()(goodDefaults, ariaProps, { + isPending: isLoadingFinal, + isDisabled, + // we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger + // onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered + onPressEnd: (e) => { + if (!isDisabled) { + handlePress(e) + } + }, // @ts-expect-error ts errors are expected here because we are merging props with different types - {...aria.mergeProps()(goodDefaults, ariaProps, { - isPending: isLoadingFinal, - isDisabled, - // we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger - // onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered - onPressEnd: (e) => { - if (!isDisabled) { - handlePress(e) - } - }, - // @ts-expect-error ts errors are expected here because we are merging props with different types - className: aria.composeRenderProps(className, (classNames, states) => - styles.base({ className: classNames, ...states }), - ), - })} - > - {(render: aria.ButtonRenderProps | aria.LinkRenderProps) => { - const shouldShowOverlayLoader = () => { - if (hideLoader) { - return false - } - - return isLoadingFinal && loaderPosition === 'full' + className: aria.composeRenderProps(className, (classNames, states) => + styles.base({ className: classNames, ...states }), + ), + })} + > + {(render: aria.ButtonRenderProps | aria.LinkRenderProps) => { + const shouldShowOverlayLoader = () => { + if (hideLoader) { + return false } - return ( - <> - - + + + - - {/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */} - {typeof children === 'function' ? children(render) : children} - + {/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */} + {typeof children === 'function' ? children(render) : children} + + + + {shouldShowOverlayLoader() && ( + + + )} - {shouldShowOverlayLoader() && ( - - - - )} + {shouldShowTooltip && visualTooltip} + - {shouldShowTooltip && visualTooltip} - - - {shouldDisplayBorder &&
} - - ) - }} - - ) + {shouldDisplayBorder &&
} + + ) + }} + + ) - if (tooltipElement == null) { - return button - } + if (tooltipElement == null) { + return button + } - return ( - - {button} + return ( + + {button} - - {tooltipElement} - - - ) - }), -) as unknown as (( - props: ButtonProps & { ref?: ForwardedRef }, -) => ReactNode) & { - /* eslint-disable @typescript-eslint/naming-convention */ - Group: typeof ButtonGroup - GroupJoin: typeof ButtonGroupJoin - GroupProvider: typeof ButtonGroupProvider - /* eslint-enable @typescript-eslint/naming-convention */ + + {tooltipElement} + + + ) } Button.Group = ButtonGroup diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx index 686b1138f64e..1d587b16786e 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx @@ -1,5 +1,5 @@ /** @file A group of buttons. */ -import { forwardRef, Fragment, type PropsWithChildren, type ReactElement } from 'react' +import { Fragment, type PropsWithChildren, type ReactElement } from 'react' import flattenChildren from 'react-keyed-flatten-children' import { tv, type VariantProps } from '#/utilities/tailwindVariants' @@ -66,13 +66,11 @@ interface ButtonGroupProps TestIdProps { readonly className?: string | undefined readonly buttonVariants?: ButtonGroupSharedButtonProps + readonly ref?: React.ForwardedRef } /** A group of buttons. */ -export const ButtonGroup = forwardRef(function ButtonGroup( - props: ButtonGroupProps, - ref: React.ForwardedRef, -) { +export function ButtonGroup(props: ButtonGroupProps) { const { children, className, @@ -85,6 +83,7 @@ export const ButtonGroup = forwardRef(function ButtonGroup( verticalAlign, buttonVariants = {}, testId = 'button-group', + ref, ...passthrough } = props @@ -114,7 +113,7 @@ export const ButtonGroup = forwardRef(function ButtonGroup(
) -}) +} /** * A wrapper for a button group that joins the buttons together. diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/CopyButton.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/CopyButton.tsx index 541cd910f646..7dd2757dd433 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/CopyButton.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/CopyButton.tsx @@ -1,5 +1,4 @@ /** @file A button that copies text to the clipboard. */ -import Error from '#/assets/cross.svg' import CopyIcon from '#/assets/duplicate.svg' import Done from '#/assets/tick.svg' import { useCopy } from '#/hooks/copyHooks' @@ -17,7 +16,6 @@ export interface CopyButtonProps * If `false` is provided, no icon will be shown. */ readonly copyIcon?: string | false - readonly errorIcon?: string readonly successIcon?: string readonly onCopy?: () => void /** @@ -35,20 +33,17 @@ export function CopyButton(props: CopyButtonProps(props: CopyButtonProps copyQuery.mutateAsync(copyText)} + onPress={() => copy(copyText)} icon={icon} /> ) diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/types.ts b/app/gui/src/dashboard/components/AriaComponents/Button/types.ts index 08180a3c0f35..fc71ff3a291c 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/types.ts +++ b/app/gui/src/dashboard/components/AriaComponents/Button/types.ts @@ -8,7 +8,7 @@ import type { PressEvent, } from '#/components/aria' import type { ExtractFunction } from '#/utilities/tailwindVariants' -import type { ReactElement, ReactNode } from 'react' +import type { ForwardedRef, ReactElement, ReactNode } from 'react' import type { Addon, IconProp, TestIdProps } from '../types' import type { BUTTON_STYLES, ButtonVariants } from './variants' @@ -57,6 +57,7 @@ interface PropsWithoutHref { export interface BaseButtonProps extends Omit, TestIdProps { + readonly ref?: ForwardedRef /** If `true`, the loader will not be shown. */ readonly hideLoader?: boolean /** Falls back to `aria-label`. Pass `false` to explicitly disable the tooltip. */ diff --git a/app/gui/src/dashboard/components/AriaComponents/Checkbox/Checkbox.tsx b/app/gui/src/dashboard/components/AriaComponents/Checkbox/Checkbox.tsx index 6b39f2e57552..e533a4cc940e 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Checkbox/Checkbox.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Checkbox/Checkbox.tsx @@ -31,7 +31,7 @@ import type { } from '../Form' import { Form } from '../Form' import { Text } from '../Text' -import type { TestIdProps } from '../types' +import type { PropsWithRef, TestIdProps } from '../types' import { CheckboxStandaloneProvider, useCheckboxContext } from './CheckboxContext' import { CheckboxGroup } from './CheckboxGroup' @@ -51,7 +51,8 @@ export type CheckboxProps & { readonly value: string readonly form?: never readonly name?: never @@ -64,7 +65,8 @@ export type StandaloneCheckboxProps< > = CheckboxSharedProps & FieldProps & FieldStateProps & - FieldVariantProps + FieldVariantProps & + PropsWithRef // eslint-disable-next-line react-refresh/only-export-components export const CHECKBOX_STYLES = tv({ @@ -113,12 +115,10 @@ export const CHECKBOX_STYLES = tv({ }) /** Checkboxes allow users to select multiple items from a list of individual items, or to mark one individual item as selected. */ -// eslint-disable-next-line no-restricted-syntax -export const Checkbox = forwardRef(function Checkbox< - Schema extends TSchema, - TFieldName extends FieldPath, ->(props: CheckboxProps, ref: ForwardedRef) { - const { form, name } = props +export function Checkbox>( + props: CheckboxProps, +) { + const { form, name, ref } = props const { store } = useCheckboxContext() const formInstance = Form.useFormContext(form) @@ -138,6 +138,8 @@ export const Checkbox = forwardRef(function Checkbox< className: _, // eslint-disable-next-line @typescript-eslint/naming-convention style: __, + // eslint-disable-next-line @typescript-eslint/naming-convention + ref: ___, ...fieldProps // This is safe, because we know that the checkbox is standalone, and @@ -181,11 +183,6 @@ export const Checkbox = forwardRef(function Checkbox< } return -}) as unknown as (>( - props: CheckboxProps & RefAttributes, -) => ReactElement) & { - // eslint-disable-next-line @typescript-eslint/naming-convention - Group: typeof CheckboxGroup } /** diff --git a/app/gui/src/dashboard/components/AriaComponents/Checkbox/CheckboxGroup.tsx b/app/gui/src/dashboard/components/AriaComponents/Checkbox/CheckboxGroup.tsx index e598cf2ee2bc..911269cd6898 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Checkbox/CheckboxGroup.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Checkbox/CheckboxGroup.tsx @@ -3,13 +3,12 @@ import type { CheckboxGroupProps as AriaCheckboxGroupProps } from '#/components/ import { CheckboxGroup as AriaCheckboxGroup, mergeProps } from '#/components/aria' import { mergeRefs } from '#/utilities/mergeRefs' import { omit } from '#/utilities/object' -import { forwardRef } from '#/utilities/react' import type { VariantProps } from '#/utilities/tailwindVariants' import { tv } from '#/utilities/tailwindVariants' import type { CSSProperties, ForwardedRef, ReactElement, ReactNode } from 'react' import type { FieldVariantProps } from '../Form' import { Form, type FieldPath, type FieldProps, type FieldStateProps, type TSchema } from '../Form' -import type { TestIdProps } from '../types' +import type { PropsWithRef, TestIdProps } from '../types' import { CheckboxGroupProvider } from './CheckboxContext' /** Props for the {@link CheckboxGroup} component. */ @@ -20,7 +19,8 @@ export interface CheckboxGroupProps< FieldProps, FieldVariantProps, Omit, 'disabled' | 'invalid'>, - TestIdProps { + TestIdProps, + PropsWithRef { readonly className?: string readonly style?: CSSProperties readonly checkboxRef?: ForwardedRef @@ -33,86 +33,85 @@ const CHECKBOX_GROUP_STYLES = tv({ }) /** A selector for one or more items from a list of choices. */ -export const CheckboxGroup = forwardRef( - >( - props: CheckboxGroupProps, - ref: ForwardedRef, - ): ReactElement => { - const { - children, - className, - variants = CHECKBOX_GROUP_STYLES, - form, - defaultValue: defaultValueOverride, - isDisabled = false, - isRequired = false, - isInvalid, - isReadOnly = false, - label, - name, - description, - fullWidth = false, - fieldVariants, - ...checkboxGroupProps - } = props +export function CheckboxGroup< + Schema extends TSchema, + TFieldName extends FieldPath, +>(props: CheckboxGroupProps): ReactElement { + const { + children, + className, + variants = CHECKBOX_GROUP_STYLES, + form, + defaultValue: defaultValueOverride, + isDisabled = false, + isRequired = false, + isInvalid, + isReadOnly = false, + label, + name, + description, + fullWidth = false, + fieldVariants, + ref, + ...checkboxGroupProps + } = props - const formInstance = Form.useFormContext(form) + const formInstance = Form.useFormContext(form) - const styles = variants({ fullWidth, className }) - const testId = props['data-testid'] ?? props.testId + const styles = variants({ fullWidth, className }) + const testId = props['data-testid'] ?? props.testId - return ( - { - const defaultValue = defaultValueOverride ?? formInstance.control._defaultValues[name] - const invalid = isInvalid ?? fieldState.invalid - return ( - <> - { - field.onChange({ target: { value } }) - void formInstance.trigger(name) - }} + return ( + { + const defaultValue = defaultValueOverride ?? formInstance.control._defaultValues[name] + const invalid = isInvalid ?? fieldState.invalid + return ( + <> + { + field.onChange({ target: { value } }) + void formInstance.trigger(name) + }} + > + ()(omit(checkboxGroupProps, 'validate'), { + className: styles, + isInvalid: invalid, + isDisabled, + isReadOnly, + name, + defaultValue: defaultValue ?? [], + })} + ref={mergeRefs(ref, field.ref)} + data-testid={testId} > - ()(omit(checkboxGroupProps, 'validate'), { - className: styles, - isInvalid: invalid, - isDisabled, - isReadOnly, - name, - defaultValue: defaultValue ?? [], - })} - ref={mergeRefs(ref, field.ref)} - data-testid={testId} - > - {(renderProps) => ( - - {typeof children === 'function' ? children(renderProps) : children} - - )} - - - - ) - }} - /> - ) - }, -) + {(renderProps) => ( + + {typeof children === 'function' ? children(renderProps) : children} + + )} + + + + ) + }} + /> + ) +} diff --git a/app/gui/src/dashboard/components/AriaComponents/CopyBlock/CopyBlock.tsx b/app/gui/src/dashboard/components/AriaComponents/CopyBlock/CopyBlock.tsx index 9464f1082cf4..263537f8814e 100644 --- a/app/gui/src/dashboard/components/AriaComponents/CopyBlock/CopyBlock.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/CopyBlock/CopyBlock.tsx @@ -42,7 +42,7 @@ export function CopyBlock(props: CopyBlockProps) { const { copyText, className, onCopy = () => {}, variants = COPY_BLOCK_STYLES } = props const { getText } = useText() - const { mutateAsync, isSuccess } = useCopy({ onCopy }) + const { copy, isCopied } = useCopy({ onCopy }) const styles = variants() @@ -50,8 +50,8 @@ export function CopyBlock(props: CopyBlockProps) {
) -}) +} /** Props for a {@link FormDropdown}. */ export interface FormDropdownProps< diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/Input/Input.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/Input/Input.tsx index 6e3c99717486..b92f39ce323f 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Inputs/Input/Input.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/Input/Input.tsx @@ -1,12 +1,5 @@ /** @file Text input. */ -import { - useRef, - type CSSProperties, - type ForwardedRef, - type ReactElement, - type ReactNode, - type Ref, -} from 'react' +import { useRef, type CSSProperties, type ReactElement, type ReactNode, type Ref } from 'react' import * as aria from '#/components/aria' import { @@ -17,6 +10,7 @@ import { type FieldProps, type FieldStateProps, type FieldVariantProps, + type PropsWithRef, type TestIdProps, type TSchema, } from '#/components/AriaComponents' @@ -42,7 +36,8 @@ export interface InputProps< FieldProps, FieldVariantProps, Omit, 'disabled' | 'invalid'>, - TestIdProps { + TestIdProps, + PropsWithRef { /** * If `true`, the input will be focused when the component is mounted. * If `select`, the input will be focused and the text will be selected. @@ -60,11 +55,11 @@ export interface InputProps< } /** Basic input component. Input component is a component that is used to get user input in a text field. */ -export const Input = forwardRef(function Input< +export function Input< Schema extends TSchema, TFieldName extends FieldPath, Constraint extends number | string = number | string, ->(props: InputProps, ref: ForwardedRef) { +>(props: InputProps) { const { name, inputRef, @@ -77,6 +72,7 @@ export const Input = forwardRef(function Input< form: formRaw, className, testId: testIdRaw, + ref, ...inputProps } = props @@ -164,7 +160,7 @@ export const Input = forwardRef(function Input< /> ) -}) +} /** Props for an {@link BasicInput}. */ export interface BasicInputProps diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/MultiSelector/MultiSelector.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/MultiSelector/MultiSelector.tsx index 213c9cbea32f..bcc49a5a66cf 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Inputs/MultiSelector/MultiSelector.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/MultiSelector/MultiSelector.tsx @@ -1,5 +1,5 @@ /** @file A horizontal selector supporting multiple input. */ -import { useRef, type CSSProperties, type ForwardedRef, type Ref } from 'react' +import { useRef, type CSSProperties, type Ref } from 'react' import { omit, unsafeRemoveUndefined } from 'enso-common/src/utilities/data/object' @@ -16,10 +16,10 @@ import { type FieldProps, type FieldStateProps, type FieldValues, + type PropsWithRef, type TSchema, } from '#/components/AriaComponents' import { mergeRefs } from '#/utilities/mergeRefs' -import { forwardRef } from '#/utilities/react' import { tv, type VariantProps } from '#/utilities/tailwindVariants' import { MultiSelectorOption, type MultiSelectorOptionProps } from './MultiSelectorOption' @@ -44,6 +44,7 @@ export interface MultiSelectorProps< readonly T[] >, FieldProps, + PropsWithRef, Omit, 'disabled' | 'invalid'> { readonly items: readonly T[] readonly children?: (item: T) => string @@ -97,11 +98,11 @@ export const MULTI_SELECTOR_STYLES = tv({ const useReadonlyArrayField = Form.makeUseField() /** A horizontal multi-selector. */ -export const MultiSelector = forwardRef(function MultiSelector< +export function MultiSelector< Schema extends TSchema, TFieldName extends FieldPath, T, ->(props: MultiSelectorProps, ref: ForwardedRef) { +>(props: MultiSelectorProps) { const { name, items, @@ -116,6 +117,7 @@ export const MultiSelector = forwardRef(function MultiSelector< rounded, isRequired = false, variant, + ref, ...inputProps } = props @@ -207,4 +209,4 @@ export const MultiSelector = forwardRef(function MultiSelector< ) -}) +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx index 990762768902..e1dbe28004ac 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx @@ -1,13 +1,7 @@ /** @file A resizable input that uses a content-editable div. */ -import { - useEffect, - useRef, - type ClipboardEvent, - type ForwardedRef, - type HTMLAttributes, -} from 'react' +import { useEffect, useRef, type ClipboardEvent, type HTMLAttributes } from 'react' -import type { FieldVariantProps } from '#/components/AriaComponents' +import type { FieldVariantProps, PropsWithRef } from '#/components/AriaComponents' import { Form, Text, @@ -19,7 +13,6 @@ import { import { useAutoFocus } from '#/hooks/autoFocusHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks' import { mergeRefs } from '#/utilities/mergeRefs' -import { forwardRef } from '#/utilities/react' import { tv, type VariantProps } from '#/utilities/tailwindVariants' import { INPUT_STYLES } from '../variants' @@ -44,6 +37,7 @@ export interface ResizableContentEditableInputProps< 'disabled' | 'invalid' | 'rounded' | 'size' | 'variant' >, FieldVariantProps, + PropsWithRef, Omit, FieldVariantProps, Pick, 'rounded' | 'size' | 'variant'>, @@ -70,13 +64,10 @@ const useStringField = Form.makeUseField() * A resizable input that uses a content-editable div. * This component might be useful for a text input that needs to have highlighted content inside of it. */ -export const ResizableContentEditableInput = forwardRef(function ResizableContentEditableInput< +export function ResizableContentEditableInput< Schema extends TSchema, TFieldName extends FieldPath, ->( - props: ResizableContentEditableInputProps, - ref: ForwardedRef, -) { +>(props: ResizableContentEditableInputProps) { const { mode = 'onInput', placeholder = '', @@ -91,6 +82,7 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten variants = CONTENT_EDITABLE_STYLES, fieldVariants, autoFocus = false, + ref, ...textFieldProps } = props @@ -185,4 +177,4 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten ) -}) +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/Selector/Selector.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/Selector/Selector.tsx index ac2751f6c706..76c93006fede 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Inputs/Selector/Selector.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/Selector/Selector.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { mergeProps, type RadioGroupProps } from '#/components/aria' -import type { FieldComponentProps } from '#/components/AriaComponents' +import type { FieldComponentProps, PropsWithRef } from '#/components/AriaComponents' import { Form, type FieldPath, @@ -15,7 +15,6 @@ import { import { AnimatedBackground } from '#/components/AnimatedBackground' import RadioGroup from '#/components/styled/RadioGroup' import { mergeRefs } from '#/utilities/mergeRefs' -import { forwardRef } from '#/utilities/react' import { tv, type VariantProps } from '#/utilities/tailwindVariants' import { SelectorOption } from './SelectorOption' @@ -29,7 +28,8 @@ export interface SelectorProps, FieldProps, Omit, 'disabled' | 'invalid' | 'variants'>, - FieldVariantProps { + FieldVariantProps, + PropsWithRef { readonly items: readonly T[] readonly children?: (item: T) => string readonly columns?: number @@ -79,11 +79,9 @@ export const SELECTOR_STYLES = tv({ }) /** A horizontal selector. */ -export const Selector = forwardRef(function Selector< - Schema extends TSchema, - TFieldName extends FieldPath, - T, ->(props: SelectorProps, ref: React.ForwardedRef) { +export function Selector, T>( + props: SelectorProps, +) { const { name, items, @@ -99,6 +97,7 @@ export const Selector = forwardRef(function Selector< isRequired = false, isInvalid = false, fieldVariants, + ref, defaultValue, ...inputProps } = props @@ -178,4 +177,4 @@ export const Selector = forwardRef(function Selector< }} /> ) -}) +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/Selector/SelectorOption.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/Selector/SelectorOption.tsx index 04237d3fa303..db7c35fc6411 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Inputs/Selector/SelectorOption.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/Selector/SelectorOption.tsx @@ -1,17 +1,17 @@ /** @file An option in a selector. */ import { AnimatedBackground } from '#/components/AnimatedBackground' import { Radio, type RadioProps } from '#/components/aria' -import { forwardRef } from '#/utilities/react' import type { VariantProps } from '#/utilities/tailwindVariants' import { tv } from '#/utilities/tailwindVariants' -import * as React from 'react' import { memo } from 'react' import { TEXT_STYLE } from '../../Text' +import type { PropsWithRef } from '../../types' /** Props for a {@link SelectorOption}. */ export interface SelectorOptionProps extends RadioProps, - VariantProps { + VariantProps, + PropsWithRef { readonly label: string } @@ -155,49 +155,45 @@ export const SELECTOR_OPTION_STYLES = tv({ }, }) -export const SelectorOption = memo( - forwardRef(function SelectorOption( - props: SelectorOptionProps, - ref: React.ForwardedRef, - ) { - const { - label, - value, - size, - rounded, - variant, - className, - variants = SELECTOR_OPTION_STYLES, - ...radioProps - } = props +export const SelectorOption = memo(function SelectorOption(props: SelectorOptionProps) { + const { + label, + value, + size, + rounded, + variant, + className, + variants = SELECTOR_OPTION_STYLES, + ref, + ...radioProps + } = props - const styles = variants({ size, rounded, variant }) + const styles = variants({ size, rounded, variant }) - return ( - + { + return styles.radio({ + className: typeof className === 'function' ? className(renderProps) : className, + ...renderProps, + }) + }} > - { - return styles.radio({ - className: typeof className === 'function' ? className(renderProps) : className, - ...renderProps, - }) - }} - > - {({ isHovered, isSelected, isPressed }) => ( - <> -
- {label} - - )} - - - ) - }), -) + {({ isHovered, isSelected, isPressed }) => ( + <> +
+ {label} + + )} + + + ) +}) diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/TimeField/TimeField.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/TimeField/TimeField.tsx index 700adc5f3e6f..943b61d7f767 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Inputs/TimeField/TimeField.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/TimeField/TimeField.tsx @@ -1,5 +1,5 @@ /** @file A date picker. */ -import { useContext, type ForwardedRef } from 'react' +import { useContext } from 'react' import type { DateSegment as DateSegmentType } from 'react-stately' @@ -23,10 +23,10 @@ import { type FieldProps, type FieldStateProps, type FieldValues, + type PropsWithRef, type TSchema, } from '#/components/AriaComponents' import { useText } from '#/providers/TextProvider' -import { forwardRef } from '#/utilities/react' import type { VariantProps } from '#/utilities/tailwindVariants' import { tv } from '#/utilities/tailwindVariants' @@ -80,7 +80,8 @@ export interface TimeFieldProps< >, FieldProps, Pick, 'className' | 'style'>, - VariantProps { + VariantProps, + PropsWithRef { readonly noResetButton?: boolean readonly segments?: Partial> } @@ -90,10 +91,9 @@ export interface TimeFieldProps< const useTimeValueField = Form.makeUseField() /** A date picker. */ -export const TimeField = forwardRef(function TimeField< - Schema extends TSchema, - TFieldName extends FieldPath, ->(props: TimeFieldProps, ref: ForwardedRef) { +export function TimeField>( + props: TimeFieldProps, +) { const { isRequired = false, noResetButton = isRequired, @@ -109,6 +109,7 @@ export const TimeField = forwardRef(function TimeField< granularity, style, isInvalid, + ref, ...rest } = props @@ -164,7 +165,7 @@ export const TimeField = forwardRef(function TimeField< /> ) -}) +} /** Props for a {@link TimeFieldResetButton}. */ interface TimeFieldResetButtonProps { diff --git a/app/gui/src/dashboard/components/AriaComponents/Switch/Switch.tsx b/app/gui/src/dashboard/components/AriaComponents/Switch/Switch.tsx index 48e52c683699..3e17ce71dc7f 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Switch/Switch.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Switch/Switch.tsx @@ -3,7 +3,7 @@ * * A switch allows a user to turn a setting on or off. */ -import { useRef, type CSSProperties, type ForwardedRef } from 'react' +import { useRef, type CSSProperties } from 'react' import { Switch as AriaSwitch, @@ -11,10 +11,10 @@ import { type SwitchProps as AriaSwitchProps, } from '#/components/aria' import { mergeRefs } from '#/utilities/mergeRefs' -import { forwardRef } from '#/utilities/react' import { tv, type VariantProps } from '#/utilities/tailwindVariants' import { Form, type FieldPath, type FieldProps, type FieldStateProps, type TSchema } from '../Form' import { TEXT_STYLE } from '../Text' +import type { PropsWithRef } from '../types' /** Props for a {@link Switch}. */ export interface SwitchProps> @@ -25,6 +25,7 @@ export interface SwitchProps, FieldProps, + PropsWithRef, Omit, 'disabled' | 'invalid'> { readonly className?: string readonly style?: CSSProperties @@ -65,10 +66,9 @@ export const SWITCH_STYLES = tv({ const useBooleanField = Form.makeUseField() /** A switch allows a user to turn a setting on or off. */ -export const Switch = forwardRef(function Switch< - Schema extends TSchema, - TFieldName extends FieldPath, ->(props: SwitchProps, ref: ForwardedRef) { +export function Switch>( + props: SwitchProps, +) { const { label, isDisabled = false, @@ -81,6 +81,7 @@ export const Switch = forwardRef(function Switch< error, size, labelPosition = 'after', + ref, ...ariaSwitchProps } = props @@ -123,7 +124,7 @@ export const Switch = forwardRef(function Switch< > { - mergeRefs(switchRef, fieldRef)(el) + mergeRefs(switchRef, fieldRef, ref)(el) }} {...mergeProps()(ariaSwitchProps, fieldProps, { defaultSelected: field.value, @@ -142,4 +143,4 @@ export const Switch = forwardRef(function Switch< ) -}) +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx b/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx index bb593917139d..0f6fa82932b6 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx @@ -8,9 +8,7 @@ import * as twv from '#/utilities/tailwindVariants' import type { TooltipElementType } from '#/components/AriaComponents' import { useEventCallback } from '#/hooks/eventCallbackHooks' -import { forwardRef } from '#/utilities/react' -import { memo } from 'react' -import type { TestIdProps } from '../types' +import type { PropsWithRef, TestIdProps } from '../types' import * as visualTooltip from '../VisualTooltip' import * as textProvider from './TextProvider' @@ -18,11 +16,12 @@ import * as textProvider from './TextProvider' export interface TextProps extends Omit, twv.VariantProps, - TestIdProps { + TestIdProps, + PropsWithRef { readonly elementType?: keyof HTMLElementTagNameMap readonly lineClamp?: number readonly tooltip?: TooltipElementType - readonly tooltipTriggerRef?: React.RefObject + readonly tooltipTriggerRef?: React.RefObject readonly tooltipDisplay?: visualTooltip.VisualTooltipOptions['display'] | 'never' readonly tooltipPlacement?: aria.Placement readonly tooltipOffset?: number @@ -146,117 +145,110 @@ export const TEXT_STYLE = twv.tv({ }) /** Text component that supports truncation and show a tooltip on hover when text is truncated */ -// eslint-disable-next-line no-restricted-syntax -export const Text = memo( - forwardRef(function Text(props: TextProps, ref: React.Ref) { - const { - className, - variant, - font, - italic, - weight, - nowrap, - monospace, - transform, - truncate, - lineClamp = 1, - children, - color, - balance, - testId, - elementType: ElementType = 'span', - tooltip: tooltipElement = children, - tooltipDisplay = 'whenOverflowing', - tooltipPlacement, - tooltipOffset, - tooltipCrossOffset, - textSelection, - disableLineHeightCompensation = false, - align, - ...ariaProps - } = props +export function Text(props: TextProps) { + const { + className, + variant, + font, + italic, + ref, + weight, + nowrap, + monospace, + transform, + truncate, + lineClamp = 1, + children, + color, + balance, + testId, + elementType: ElementType = 'span', + tooltip: tooltipElement = children, + tooltipDisplay = 'whenOverflowing', + tooltipPlacement, + tooltipOffset, + tooltipCrossOffset, + textSelection, + disableLineHeightCompensation = false, + align, + ...ariaProps + } = props - const textElementRef = React.useRef(null) - const textContext = textProvider.useTextContext() + const textElementRef = React.useRef(null) + const textContext = textProvider.useTextContext() - const textClasses = TEXT_STYLE({ - variant, - font, - weight, - transform, - monospace, - italic, - nowrap, - truncate, - color, - balance, - textSelection, - disableLineHeightCompensation: - disableLineHeightCompensation === false ? - textContext.isInsideTextComponent - : disableLineHeightCompensation, - className, - align, - }) + const textClasses = TEXT_STYLE({ + variant, + font, + weight, + transform, + monospace, + italic, + nowrap, + truncate, + color, + balance, + textSelection, + disableLineHeightCompensation: + disableLineHeightCompensation === false ? + textContext.isInsideTextComponent + : disableLineHeightCompensation, + className, + align, + }) - const isTooltipDisabled = useEventCallback(() => { - if (tooltipDisplay === 'whenOverflowing') { - return truncate == null - } - if (tooltipDisplay === 'always') { - return tooltipElement === false || tooltipElement == null - } + const isTooltipDisabled = useEventCallback(() => { + if (tooltipDisplay === 'whenOverflowing') { + return truncate == null + } + if (tooltipDisplay === 'always') { + return tooltipElement === false || tooltipElement == null + } - return tooltipDisplay === 'never' - }) + return tooltipDisplay === 'never' + }) - const { tooltip, targetProps } = visualTooltip.useVisualTooltip({ - isDisabled: isTooltipDisabled(), - targetRef: textElementRef, - display: tooltipDisplay === 'never' ? () => false : tooltipDisplay, - children: tooltipElement, - ...(tooltipPlacement || tooltipOffset != null || tooltipCrossOffset != null ? - { - overlayPositionProps: { - ...(tooltipPlacement && { placement: tooltipPlacement }), - ...(tooltipOffset != null && { offset: tooltipOffset }), - ...(tooltipCrossOffset != null && { crossOffset: tooltipCrossOffset }), - }, - } - : {}), - }) + const { tooltip, targetProps } = visualTooltip.useVisualTooltip({ + isDisabled: isTooltipDisabled(), + targetRef: textElementRef, + display: tooltipDisplay === 'never' ? () => false : tooltipDisplay, + children: tooltipElement, + ...(tooltipPlacement || tooltipOffset != null || tooltipCrossOffset != null ? + { + overlayPositionProps: { + ...(tooltipPlacement && { placement: tooltipPlacement }), + ...(tooltipOffset != null && { offset: tooltipOffset }), + ...(tooltipCrossOffset != null && { crossOffset: tooltipCrossOffset }), + }, + } + : {}), + }) - return ( - - { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - mergeRefs.mergeRefs(ref, textElementRef)(el) - }} - data-testid={testId} - className={textClasses} - {...aria.mergeProps>()( - ariaProps, - targetProps, - truncate === 'custom' ? - // eslint-disable-next-line @typescript-eslint/naming-convention,no-restricted-syntax - ({ style: { '--line-clamp': `${lineClamp}` } } as React.HTMLAttributes) - : {}, - )} - > - {children} - + return ( + + { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + mergeRefs.mergeRefs(ref, textElementRef)(el) + }} + data-testid={testId} + className={textClasses} + {...aria.mergeProps>()( + ariaProps, + targetProps, + truncate === 'custom' ? + // eslint-disable-next-line @typescript-eslint/naming-convention,no-restricted-syntax + ({ style: { '--line-clamp': `${lineClamp}` } } as React.HTMLAttributes) + : {}, + )} + > + {children} + - {tooltip} - - ) - }), -) as unknown as React.FC & TextProps> & { - // eslint-disable-next-line @typescript-eslint/naming-convention - Heading: typeof Heading - // eslint-disable-next-line @typescript-eslint/naming-convention - Group: React.FC + {tooltip} + + ) } /** Heading props */ @@ -266,12 +258,11 @@ export interface HeadingProps extends Omit { } /** Heading component */ -const Heading = memo( - forwardRef(function Heading(props: HeadingProps, ref: React.Ref) { - const { level = 1, ...textProps } = props - return - }), -) +function Heading(props: HeadingProps) { + const { level = 1, ...textProps } = props + + return +} /** Text group component. It's used to visually group text elements together */ function TextGroup(props: React.PropsWithChildren) { diff --git a/app/gui/src/dashboard/components/AriaComponents/VisualTooltip/useVisualTooltip.tsx b/app/gui/src/dashboard/components/AriaComponents/VisualTooltip/useVisualTooltip.tsx index c2b47d5ce8e1..eb8772d02078 100644 --- a/app/gui/src/dashboard/components/AriaComponents/VisualTooltip/useVisualTooltip.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/VisualTooltip/useVisualTooltip.tsx @@ -10,8 +10,8 @@ export interface VisualTooltipOptions extends Pick { readonly children: React.ReactNode readonly className?: string - readonly targetRef: React.RefObject - readonly triggerRef?: React.RefObject | undefined + readonly targetRef: React.RefObject + readonly triggerRef?: React.RefObject | undefined readonly isDisabled?: boolean readonly overlayPositionProps?: Pick< aria.AriaPositionProps, @@ -30,7 +30,7 @@ export interface VisualTooltipOptions /** The return value of the {@link useVisualTooltip} hook. */ export interface VisualTooltipReturn { readonly targetProps: aria.DOMAttributes & { readonly id: string } - readonly tooltip: JSX.Element | null + readonly tooltip: React.ReactNode | null } /** The display strategy for the tooltip. */ @@ -132,8 +132,8 @@ interface TooltipInnerProps readonly disabled: boolean readonly handleHoverChange: (isHovered: boolean) => void readonly state: aria.TooltipTriggerState - readonly targetRef: React.RefObject - readonly triggerRef: React.RefObject + readonly targetRef: React.RefObject + readonly triggerRef: React.RefObject readonly children: React.ReactNode readonly className?: string | undefined readonly testId?: string | undefined @@ -209,11 +209,9 @@ function TooltipInner(props: TooltipInnerProps) { }), // eslint-disable-next-line @typescript-eslint/naming-convention 'aria-hidden': true, - // Note that this is a `@ts-expect-error` so that an update to the outdated type - // definitions will notify that this `@ts-expect-error` can be safely removed. - // @ts-expect-error This is a new DOM property. popover: '', role: 'presentation', + // @ts-expect-error No idea why this is an error. 'data-testid': testId, // Remove z-index from the overlay style because it is not needed. // We show the latest element on top, and z-index can cause issues with diff --git a/app/gui/src/dashboard/components/AriaComponents/types.ts b/app/gui/src/dashboard/components/AriaComponents/types.ts index d23946b84cd6..0b4b784f9092 100644 --- a/app/gui/src/dashboard/components/AriaComponents/types.ts +++ b/app/gui/src/dashboard/components/AriaComponents/types.ts @@ -1,6 +1,6 @@ /** @file Common types for WAI-ARIA components. */ import type { Icon as PossibleIcon } from '@/util/iconMetadata/iconName' -import type { ReactElement } from 'react' +import type { ForwardedRef, ReactElement } from 'react' export type { Placement } from 'react-aria' /** Props for adding a test id to a component */ @@ -10,6 +10,13 @@ export interface TestIdProps { readonly testId?: string | undefined } +/** + * Props for adding a ref to a component. + */ +export interface PropsWithRef { + readonly ref?: ForwardedRef | undefined +} + /** Any icon. */ export type IconProp = | IconPropSvgUse diff --git a/app/gui/src/dashboard/components/Await.tsx b/app/gui/src/dashboard/components/Await.tsx index 1b236ad6c988..6f822a4fcb79 100644 --- a/app/gui/src/dashboard/components/Await.tsx +++ b/app/gui/src/dashboard/components/Await.tsx @@ -3,9 +3,8 @@ * * Await a promise and render the children when the promise is resolved. */ -import { type ReactNode } from 'react' +import { use, type ReactNode } from 'react' -import invariant from 'tiny-invariant' import { ErrorBoundary, type ErrorBoundaryProps } from './ErrorBoundary' import { Suspense, type SuspenseProps } from './Suspense' @@ -87,8 +86,6 @@ export function Await(props: AwaitProps) { ) } -const PRIVATE_AWAIT_PROMISE_STATE = Symbol('PRIVATE_AWAIT_PROMISE_STATE_REF') - /** * Internal implementation of the {@link Await} component. * @@ -99,84 +96,7 @@ const PRIVATE_AWAIT_PROMISE_STATE = Symbol('PRIVATE_AWAIT_PROMISE_STATE_REF') function AwaitInternal(props: AwaitProps) { const { promise, children } = props - const data = useAwait(promise) + const data = use(promise) return typeof children === 'function' ? children(data) : children } - -export function useAwait(promise?: null): void -export function useAwait(promise: Promise): PromiseType -export function useAwait( - promise?: Promise | null, -): PromiseType | undefined - -/** - * A hook that accepts a promise and triggers the Suspense boundary until the promise is resolved. - * @param promise - The promise to await. - * @throws {Promise} - The promise that is being awaited by Suspense - * @returns The data of the promise. - */ -// eslint-disable-next-line react-refresh/only-export-components -export function useAwait( - promise?: Promise | null, -): PromiseType | undefined { - if (promise == null) { - return - } - - /** - * Define the promise state on the promise. - */ - const definePromiseState = ( - promiseToDefineOn: Promise, - promiseState: PromiseState, - ) => { - // @ts-expect-error: we know that the promise state is not defined in the type but it's fine, - // because it's a private and scoped to the component. - promiseToDefineOn[PRIVATE_AWAIT_PROMISE_STATE] = promiseState - } - - // We need to define the promise state, only once. - // We don't want to use refs on state, because it scopes the state to the component. - // But we might use multiple Await components with the same promise. - if (!(PRIVATE_AWAIT_PROMISE_STATE in promise)) { - definePromiseState(promise, { status: 'pending' }) - - // This breaks the chain of promises, but it's fine, - // because this is suppsed to the last in the chain. - // and the error will be thrown in the render phase - // to trigger the error boundary. - void promise.then((data) => { - definePromiseState(promise, { status: 'success', data }) - }) - void promise.catch((error) => { - definePromiseState(promise, { status: 'error', error }) - }) - } - - // This should never happen, as the promise state is defined above. - // But we need to check it, because the promise state is not defined in the type. - // And we want to make TypeScript happy. - invariant( - PRIVATE_AWAIT_PROMISE_STATE in promise, - 'Promise state is not defined. This should never happen.', - ) - - const promiseState = - // This is safe, as we defined the promise state above. - // and it always present in the promise object. - // eslint-disable-next-line no-restricted-syntax - promise[PRIVATE_AWAIT_PROMISE_STATE] as PromiseState - - if (promiseState.status === 'pending') { - // Throwing a promise is the valid way to trigger Suspense - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw promise - } - - if (promiseState.status === 'error') { - throw promiseState.error - } - - return promiseState.data -} diff --git a/app/gui/src/dashboard/components/ColorPicker.tsx b/app/gui/src/dashboard/components/ColorPicker.tsx index 97a29d1bab44..33fdf9d7d22a 100644 --- a/app/gui/src/dashboard/components/ColorPicker.tsx +++ b/app/gui/src/dashboard/components/ColorPicker.tsx @@ -12,7 +12,6 @@ import RadioGroup from '#/components/styled/RadioGroup' import * as backend from '#/services/Backend' -import { forwardRef } from '#/utilities/react' import * as tailwindMerge from '#/utilities/tailwindMerge' /** Props for a {@link ColorPickerItem}. */ @@ -51,14 +50,12 @@ export interface ColorPickerProps extends Readonly void + readonly ref?: React.ForwardedRef } /** A color picker to select from a predetermined list of colors. */ -export default forwardRef(ColorPicker) - -/** A color picker to select from a predetermined list of colors. */ -function ColorPicker(props: ColorPickerProps, ref: React.ForwardedRef) { - const { className, pickerClassName = '', children, setColor, ...radioGroupProps } = props +export default function ColorPicker(props: ColorPickerProps) { + const { className, pickerClassName = '', children, setColor, ref, ...radioGroupProps } = props return ( + readonly formRef: React.RefObject } /** diff --git a/app/gui/src/dashboard/components/JSONSchemaInput.tsx b/app/gui/src/dashboard/components/JSONSchemaInput.tsx index e29dbc7ced81..34e7edd6c1f9 100644 --- a/app/gui/src/dashboard/components/JSONSchemaInput.tsx +++ b/app/gui/src/dashboard/components/JSONSchemaInput.tsx @@ -1,5 +1,5 @@ /** @file A dynamic wizard for creating an arbitrary type of Datalink. */ -import { Fragment, type JSX, useState } from 'react' +import { Fragment, useState } from 'react' import { Input } from '#/components/aria' import { Button, Checkbox, Dropdown, Text } from '#/components/AriaComponents' @@ -12,6 +12,7 @@ import { constantValueOfSchema, getSchemaName, lookupDef } from '#/utilities/jso import { asObject, singletonObjectOrNull } from '#/utilities/object' import { twMerge } from '#/utilities/tailwindMerge' import { useQuery } from '@tanstack/react-query' +import * as React from 'react' import { twJoin } from 'tailwind-merge' /** Props for a {@link JSONSchemaInput}. */ @@ -63,7 +64,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) { // This value cannot change. return null } else { - const children: JSX.Element[] = [] + const children: React.JSX.Element[] = [] if ('type' in schema) { switch (schema.type) { case 'string': { diff --git a/app/gui/src/dashboard/components/Link.tsx b/app/gui/src/dashboard/components/Link.tsx index a4177d22fdfd..bd53396ec347 100644 --- a/app/gui/src/dashboard/components/Link.tsx +++ b/app/gui/src/dashboard/components/Link.tsx @@ -8,11 +8,11 @@ import SvgMask from '#/components/SvgMask' import { useFocusChild } from '#/hooks/focusHooks' import { useText } from '#/providers/TextProvider' import { mergeRefs } from '#/utilities/mergeRefs' -import { forwardRef } from '#/utilities/react' import { twMerge } from 'tailwind-merge' +import type { PropsWithRef } from './AriaComponents' /** Props for a {@link Link}. */ -export interface LinkProps { +export interface LinkProps extends PropsWithRef { readonly onPress?: () => void readonly openInBrowser?: boolean readonly to: string @@ -20,11 +20,9 @@ export interface LinkProps { readonly text: string } -export default forwardRef(Link) - /** A styled colored link with an icon. */ -function Link(props: LinkProps, ref: React.ForwardedRef) { - const { openInBrowser = false, to, icon, text, onPress } = props +export default function Link(props: LinkProps) { + const { openInBrowser = false, to, icon, text, onPress, ref } = props const { getText } = useText() const { className: focusChildClassName, ...focusChildProps } = useFocusChild() const linkRef = React.useRef(null) diff --git a/app/gui/src/dashboard/components/MarkdownViewer/MarkdownViewer.tsx b/app/gui/src/dashboard/components/MarkdownViewer/MarkdownViewer.tsx index bdba0bc65bf7..0c628ff235bc 100644 --- a/app/gui/src/dashboard/components/MarkdownViewer/MarkdownViewer.tsx +++ b/app/gui/src/dashboard/components/MarkdownViewer/MarkdownViewer.tsx @@ -1,5 +1,4 @@ /** @file A Markdown viewer component. */ -import * as React from 'react' import { useLogger } from '#/providers/LoggerProvider' import { useText } from '#/providers/TextProvider' @@ -7,6 +6,7 @@ import { resolveDocImageUrl } from '@/components/DocumentationEditor/images' import { type UrlTransformer } from '@/components/MarkdownEditor/imageUrlTransformer' import { Err, Ok } from '@/util/data/result' import { type TestIdProps } from '../AriaComponents' +import { MarkdownEditor } from './defaultRenderer' /** Props for a {@link MarkdownViewer}. */ export interface MarkdownViewerProps extends TestIdProps { @@ -15,13 +15,6 @@ export interface MarkdownViewerProps extends TestIdProps { readonly imgUrlResolver: (relativePath: string) => Promise } -const LazyMarkdownEditor = React.lazy(() => - import('#/components/MarkdownViewer/defaultRenderer').then( - // eslint-disable-next-line @typescript-eslint/naming-convention - ({ MarkdownEditor }) => MarkdownEditor, - ), -) - /** * Markdown viewer component. * Parses markdown passed in as a `text` prop into HTML and displays it. @@ -51,7 +44,7 @@ export function MarkdownViewer(props: MarkdownViewerProps) { } return ( - @@ -43,7 +42,7 @@ export function PaywallAlert( variant="outline" size="small" rounded="xlarge" - className={clsx('border border-primary/20', className)} + className={twJoin('border border-primary/20', className)} {...alertProps} >
diff --git a/app/gui/src/dashboard/components/Result.tsx b/app/gui/src/dashboard/components/Result.tsx index 20a5aa60b257..80cbe0470923 100644 --- a/app/gui/src/dashboard/components/Result.tsx +++ b/app/gui/src/dashboard/components/Result.tsx @@ -2,7 +2,7 @@ import Success from '#/assets/check_mark.svg' import Error from '#/assets/cross.svg' import { tv, type VariantProps } from '#/utilities/tailwindVariants' -import type { JSX, PropsWithChildren, ReactElement } from 'react' +import type { PropsWithChildren, ReactElement } from 'react' import type { TestIdProps } from './AriaComponents' import { Text } from './AriaComponents/Text' import { Loader } from './Loader' @@ -81,8 +81,8 @@ export interface ResultProps VariantProps, TestIdProps { readonly className?: string - readonly title?: JSX.Element | string - readonly subtitle?: JSX.Element | string + readonly title?: React.JSX.Element | string + readonly subtitle?: React.JSX.Element | string /** * The status of the result. * @default 'success' diff --git a/app/gui/src/dashboard/components/SelectionBrush.tsx b/app/gui/src/dashboard/components/SelectionBrush.tsx index 0b1a3d02879c..42218238bfe2 100644 --- a/app/gui/src/dashboard/components/SelectionBrush.tsx +++ b/app/gui/src/dashboard/components/SelectionBrush.tsx @@ -39,7 +39,7 @@ export interface SelectionBrushV2Props { readonly onDragEnd?: (event: PointerEvent) => void readonly onDragCancel?: () => void - readonly targetRef: React.RefObject + readonly targetRef: React.RefObject readonly isDisabled?: boolean readonly preventDrag?: (event: PointerEvent) => boolean } diff --git a/app/gui/src/dashboard/components/__tests__/Await.test.tsx b/app/gui/src/dashboard/components/__tests__/Await.test.tsx deleted file mode 100644 index d7eeebdb7fef..000000000000 --- a/app/gui/src/dashboard/components/__tests__/Await.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { act, render, screen } from '@testing-library/react' -import { describe, vi } from 'vitest' -import { Await } from '../Await' - -describe('', (it) => { - it('should the suspense boundary before promise is resolved, then show the children once promise is resolved', async ({ - expect, - }) => { - const promise = Promise.resolve('Hello') - render({(value) =>
{value}
}
) - - expect(screen.queryByText('Hello')).not.toBeInTheDocument() - expect(screen.getByTestId('spinner')).toBeInTheDocument() - - await act(() => promise) - - expect(screen.getByText('Hello')).toBeInTheDocument() - expect(screen.queryByTestId('spinner')).not.toBeInTheDocument() - }) - - // This test is SUPPOSED to throw an error, - // Because the only way to test the error boundary is to throw an error during the render phase. - // We do not want to catch the error before we render the component, - // because in that case, the error boundary will not be triggered. - it('should show the fallback if the promise is rejected', async ({ expect }) => { - // Suppress the error message from the console caused by React Error Boundary - vi.spyOn(console, 'error').mockImplementation(() => {}) - - const rejectionPromise = new Promise((resolve) => process.once('unhandledRejection', resolve)) - const errorPromise = Promise.reject(new Error('💣')) - - render({() => <>Hello}) - await expect(rejectionPromise).resolves.toEqual(new Error('💣')) - - expect(screen.getByTestId('spinner')).toBeInTheDocument() - - await act(() => errorPromise.catch(() => {})) - - expect(screen.queryByText('Hello')).not.toBeInTheDocument() - expect(screen.queryByTestId('spinner')).not.toBeInTheDocument() - expect(screen.getByTestId('error-display')).toBeInTheDocument() - // eslint-disable-next-line no-restricted-properties - expect(console.error).toHaveBeenCalled() - }) - - it('should not display the Suspense boundary of the second Await if the first Await already resolved', async ({ - expect, - }) => { - const promise = Promise.resolve('Hello') - const { unmount } = render({(value) =>
{value}
}
) - - await act(() => promise) - - expect(screen.getByText('Hello')).toBeInTheDocument() - - unmount() - - render({(value) =>
{value}
}
) - - expect(screen.getByText('Hello')).toBeInTheDocument() - }) -}) diff --git a/app/gui/src/dashboard/components/dashboard/column.ts b/app/gui/src/dashboard/components/dashboard/column.ts index 9663fbf1c093..8a2e40be9e81 100644 --- a/app/gui/src/dashboard/components/dashboard/column.ts +++ b/app/gui/src/dashboard/components/dashboard/column.ts @@ -1,5 +1,5 @@ /** @file Column types and column display modes. */ -import { memo, type Dispatch, type JSX, type SetStateAction } from 'react' +import { memo, type Dispatch, type SetStateAction } from 'react' import type { AssetRowState, AssetsTableState } from '#/layouts/AssetsTable' import type { Category } from '#/layouts/CategorySwitcher/Category' @@ -45,8 +45,8 @@ export interface AssetColumnHeadingProps { export interface AssetColumn { readonly id: string readonly className?: string - readonly heading: (props: AssetColumnHeadingProps) => JSX.Element - readonly render: (props: AssetColumnProps) => JSX.Element + readonly heading: (props: AssetColumnHeadingProps) => React.JSX.Element + readonly render: (props: AssetColumnProps) => React.JSX.Element } /** React components for every column. */ diff --git a/app/gui/src/dashboard/components/styled/FocusArea.tsx b/app/gui/src/dashboard/components/styled/FocusArea.tsx index 383c2d0cc36e..ae32bbc8c67a 100644 --- a/app/gui/src/dashboard/components/styled/FocusArea.tsx +++ b/app/gui/src/dashboard/components/styled/FocusArea.tsx @@ -1,5 +1,5 @@ /** @file An area that contains focusable children. */ -import { type JSX, type RefCallback, useMemo, useRef, useState } from 'react' +import { type RefCallback, useMemo, useRef, useState } from 'react' import { IS_DEV_MODE } from 'enso-common/src/detect' @@ -27,7 +27,7 @@ export interface FocusAreaProps { readonly focusDefaultClass?: string readonly active?: boolean readonly direction: FocusDirection - readonly children: (props: FocusWithinProps) => JSX.Element + readonly children: (props: FocusWithinProps) => React.JSX.Element } /** diff --git a/app/gui/src/dashboard/components/styled/RadioGroup.tsx b/app/gui/src/dashboard/components/styled/RadioGroup.tsx index e82d7902cf61..de4015df25c3 100644 --- a/app/gui/src/dashboard/components/styled/RadioGroup.tsx +++ b/app/gui/src/dashboard/components/styled/RadioGroup.tsx @@ -21,7 +21,7 @@ import * as React from 'react' import * as reactStately from 'react-stately' import * as aria from '#/components/aria' -import { forwardRef } from '#/utilities/react' +import type { PropsWithRef } from '../AriaComponents' /** Options for {@link useRenderProps}. */ interface RenderPropsHookOptions extends aria.DOMProps, aria.AriaLabelingProps { @@ -101,10 +101,9 @@ function useSlot(): [React.RefCallback, boolean] { const UNDEFINED = undefined /** A radio group allows a user to select a single item from a list of mutually exclusive options. */ -export default forwardRef(RadioGroup) - -/** A radio group allows a user to select a single item from a list of mutually exclusive options. */ -function RadioGroup(props: aria.RadioGroupProps, ref: React.ForwardedRef) { +export default function RadioGroup(props: aria.RadioGroupProps & PropsWithRef) { + let { ref } = props + // @ts-expect-error Ref could be undefined ;[props, ref] = aria.useContextProps(props, ref, aria.RadioGroupContext) const state = reactStately.useRadioGroupState({ ...props, diff --git a/app/gui/src/dashboard/hooks/autoFocusHooks.ts b/app/gui/src/dashboard/hooks/autoFocusHooks.ts index 1421fa419ba7..92970a077628 100644 --- a/app/gui/src/dashboard/hooks/autoFocusHooks.ts +++ b/app/gui/src/dashboard/hooks/autoFocusHooks.ts @@ -12,7 +12,7 @@ import { useUnmount } from './unmountHooks' /** Props for the {@link useAutoFocus} hook. */ export interface UseAutoFocusProps { - readonly ref: React.RefObject + readonly ref: React.RefObject readonly disabled?: boolean | undefined /** * Called when the element is focused. diff --git a/app/gui/src/dashboard/hooks/backendBatchedHooks.ts b/app/gui/src/dashboard/hooks/backendBatchedHooks.ts index 8419e2780e06..b4aa37ca5f6b 100644 --- a/app/gui/src/dashboard/hooks/backendBatchedHooks.ts +++ b/app/gui/src/dashboard/hooks/backendBatchedHooks.ts @@ -67,8 +67,10 @@ export function useDeleteAssetsMutationState( return useMutationState({ filters: { ...deleteAssetsMutationOptions(backend), - predicate: (mutation: DeleteAssetsMutation) => - mutation.state.status === 'pending' && (predicate?.(mutation) ?? true), + predicate: (mutation: Mutation) => + mutation.state.status === 'pending' && + // eslint-disable-next-line no-restricted-syntax + (predicate?.(mutation as DeleteAssetsMutation) ?? true), }, // This is UNSAFE when the `Result` parameter is explicitly specified in the // generic parameter list. @@ -133,8 +135,10 @@ export function useRestoreAssetsMutationState( return useMutationState({ filters: { ...restoreAssetsMutationOptions(backend), - predicate: (mutation: RestoreAssetsMutation) => - mutation.state.status === 'pending' && (predicate?.(mutation) ?? true), + predicate: (mutation: Mutation) => + mutation.state.status === 'pending' && + // eslint-disable-next-line no-restricted-syntax + (predicate?.(mutation as RestoreAssetsMutation) ?? true), }, // This is UNSAFE when the `Result` parameter is explicitly specified in the // generic parameter list. @@ -196,8 +200,10 @@ export function useCopyAssetsMutationState( return useMutationState({ filters: { ...copyAssetsMutationOptions(backend), - predicate: (mutation: CopyAssetsMutation) => - mutation.state.status === 'pending' && (predicate?.(mutation) ?? true), + predicate: (mutation: Mutation) => + mutation.state.status === 'pending' && + // eslint-disable-next-line no-restricted-syntax + (predicate?.(mutation as CopyAssetsMutation) ?? true), }, // This is UNSAFE when the `Result` parameter is explicitly specified in the // generic parameter list. @@ -301,8 +307,10 @@ export function useMoveAssetsMutationState( return useMutationState({ filters: { ...moveAssetsMutationOptions(backend), - predicate: (mutation: MoveAssetsMutation) => - mutation.state.status === 'pending' && (predicate?.(mutation) ?? true), + predicate: (mutation: Mutation) => + mutation.state.status === 'pending' && + // eslint-disable-next-line no-restricted-syntax + (predicate?.(mutation as MoveAssetsMutation) ?? true), }, // This is UNSAFE when the `Result` parameter is explicitly specified in the // generic parameter list. diff --git a/app/gui/src/dashboard/hooks/backendHooks.ts b/app/gui/src/dashboard/hooks/backendHooks.ts index 8afe521b4ea1..83a4cb9b014e 100644 --- a/app/gui/src/dashboard/hooks/backendHooks.ts +++ b/app/gui/src/dashboard/hooks/backendHooks.ts @@ -425,8 +425,10 @@ export function useBackendMutationState) => - mutation.state.status === 'pending' && (predicate?.(mutation) ?? true), + predicate: (mutation: Mutation) => + mutation.state.status === 'pending' && + // eslint-disable-next-line no-restricted-syntax + (predicate?.(mutation as BackendMutation) ?? true), }, // This is UNSAFE when the `Result` parameter is explicitly specified in the // generic parameter list. diff --git a/app/gui/src/dashboard/hooks/copyHooks.ts b/app/gui/src/dashboard/hooks/copyHooks.ts index e11f3955dd8a..5230168bf668 100644 --- a/app/gui/src/dashboard/hooks/copyHooks.ts +++ b/app/gui/src/dashboard/hooks/copyHooks.ts @@ -6,12 +6,10 @@ import * as React from 'react' -import * as reactQuery from '@tanstack/react-query' import * as toastify from 'react-toastify' -import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' - import * as textProvider from '#/providers/TextProvider' +import { useEventCallback } from './eventCallbackHooks' /** Props for the useCopy hook. */ export interface UseCopyProps { @@ -27,49 +25,46 @@ export function useCopy(props: UseCopyProps = {}) { const resetTimeoutIdRef = React.useRef | null>(null) const { getText } = textProvider.useText() - const toastAndLog = toastAndLogHooks.useToastAndLog() - const copyQuery = reactQuery.useMutation({ - mutationFn: (text: string) => { - return navigator.clipboard.writeText(text) - }, - onMutate: () => { - // Clear the reset timeout. - // This is necessary to prevent the button from resetting while the copy is in progress. - // This can happen if the user clicks the button multiple times in quick succession. - if (resetTimeoutIdRef.current != null) { - clearTimeout(resetTimeoutIdRef.current) - resetTimeoutIdRef.current = null - } - }, - onSuccess: () => { - onCopy?.() + const [isCopying, startTransition] = React.useTransition() + const [isCopied, setIsCopied] = React.useOptimistic(false) + + const copy = useEventCallback( + (text: string) => + new Promise((resolve) => { + startTransition(async () => { + await navigator.clipboard.writeText(text) + setIsCopied(true) + onCopy?.() - const toastId = 'copySuccess' + const toastId = 'copySuccess' - if (successToastMessage !== false) { - toastify.toast.success( - successToastMessage === true ? getText('copiedToClipboard') : successToastMessage, - { toastId, closeOnClick: true, hideProgressBar: true, position: 'bottom-right' }, - ) - // If user closes the toast, reset the button state - toastify.toast.onChange((toast) => { - if (toast.id === toastId && toast.status === 'removed') { - copyQuery.reset() + if (successToastMessage !== false) { + toastify.toast.success( + successToastMessage === true ? getText('copiedToClipboard') : successToastMessage, + { toastId, closeOnClick: true, hideProgressBar: true, position: 'bottom-right' }, + ) } - }) - } - // Reset the button to its original state after a timeout. - resetTimeoutIdRef.current = setTimeout(() => { - toastify.toast.dismiss(toastId) - copyQuery.reset() - }, DEFAULT_TIMEOUT) - }, - onError: (error) => { - toastAndLog('arbitraryErrorTitle', error) - }, - }) + await new Promise((timeoutResolve) => { + // Reset the button to its original state after a timeout. + resetTimeoutIdRef.current = setTimeout(() => { + toastify.toast.dismiss(toastId) + timeoutResolve() + }, DEFAULT_TIMEOUT) + + // If user closes the toast, reset the button state + toastify.toast.onChange((toast) => { + if (toast.id === toastId && toast.status === 'removed') { + timeoutResolve() + } + }) + }) + + resolve() + }) + }), + ) - return copyQuery + return { copy, isCopying, isCopied } } diff --git a/app/gui/src/dashboard/hooks/debounceCallbackHooks.ts b/app/gui/src/dashboard/hooks/debounceCallbackHooks.ts index b97074c8398f..f1209dbe98dc 100644 --- a/app/gui/src/dashboard/hooks/debounceCallbackHooks.ts +++ b/app/gui/src/dashboard/hooks/debounceCallbackHooks.ts @@ -13,20 +13,20 @@ export function useDebouncedCallback unknown>( ): DebouncedFunction { const stableCallback = useEventCallback(callback) - const timeoutIdRef = React.useRef>() - const waitTimeoutIdRef = React.useRef>() + const timeoutIdRef = React.useRef | null>(null) + const waitTimeoutIdRef = React.useRef | null>(null) - const lastCallRef = React.useRef<{ args: Parameters }>() + const lastCallRef = React.useRef<{ args: Parameters } | null>(null) const clear = useEventCallback(() => { if (timeoutIdRef.current) { clearTimeout(timeoutIdRef.current) - timeoutIdRef.current = undefined + timeoutIdRef.current = null } if (waitTimeoutIdRef.current) { clearTimeout(waitTimeoutIdRef.current) - waitTimeoutIdRef.current = undefined + waitTimeoutIdRef.current = null } }) @@ -36,7 +36,7 @@ export function useDebouncedCallback unknown>( } const context = lastCallRef.current - lastCallRef.current = undefined + lastCallRef.current = null stableCallback(...context.args) diff --git a/app/gui/src/dashboard/hooks/debugHooks.ts b/app/gui/src/dashboard/hooks/debugHooks.ts index df42e6255fd8..d6e02d6ea2a7 100644 --- a/app/gui/src/dashboard/hooks/debugHooks.ts +++ b/app/gui/src/dashboard/hooks/debugHooks.ts @@ -53,6 +53,7 @@ export function useMonitorDependencies( ) { const oldDependenciesRef = React.useRef(dependencies) if (active) { + // eslint-disable-next-line react-compiler/react-compiler const indicesOfChangedDependencies = dependencies.flatMap((dep, i) => Object.is(dep, oldDependenciesRef.current[i]) ? [] : [i], ) @@ -61,6 +62,7 @@ export function useMonitorDependencies( console.group(`dependencies changed${descriptionText}`) for (const i of indicesOfChangedDependencies) { console.group(dependencyDescriptions?.[i] ?? `dependency #${i + 1}`) + // eslint-disable-next-line react-compiler/react-compiler console.log('old value:', oldDependenciesRef.current[i]) console.log('new value:', dependencies[i]) console.groupEnd() diff --git a/app/gui/src/dashboard/hooks/eventListenerHooks.ts b/app/gui/src/dashboard/hooks/eventListenerHooks.ts index 0473d8798d5a..35a32882fb89 100644 --- a/app/gui/src/dashboard/hooks/eventListenerHooks.ts +++ b/app/gui/src/dashboard/hooks/eventListenerHooks.ts @@ -27,7 +27,7 @@ function useEventListener( function useEventListener( eventName: K, handler: (event: HTMLElementEventMap[K]) => void, - element: RefObject | T, + element: RefObject | T, options?: UseEventListenerParams | boolean, ): void @@ -52,7 +52,7 @@ function useEventListener< >( eventName: KH | KW, handler: (event: Event | HTMLElementEventMap[KH] | WindowEventMap[KW]) => void, - element: RefObject | T, + element: RefObject | T, options: UseEventListenerParams | boolean = { passive: true }, ) { const { @@ -130,7 +130,7 @@ function elementIsHTMLElement(element: unknown): element is HTMLElement { /** * Check if the element is a RefObject. */ -function elementIsRef(element: unknown): element is RefObject { +function elementIsRef(element: unknown): element is RefObject { if (elementIsDocument(element)) { return false } diff --git a/app/gui/src/dashboard/hooks/projectHooks.ts b/app/gui/src/dashboard/hooks/projectHooks.ts index 866381263269..9b3dfabd713c 100644 --- a/app/gui/src/dashboard/hooks/projectHooks.ts +++ b/app/gui/src/dashboard/hooks/projectHooks.ts @@ -432,8 +432,7 @@ function useOpenProject() { const openingProjectMutation = client.getMutationCache().find({ mutationKey: ['openProject'], - // This is unsafe, but we can't do anything about it. - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + // @ts-expect-error This is unsafe, but we can't do anything about it. predicate: (mutation) => mutation.state.variables?.id === project.id, }) openingProjectMutation?.setOptions({ @@ -579,8 +578,7 @@ export function useCloseProject() { .getMutationCache() .findAll({ mutationKey: ['closeProject'], - // This is unsafe, but we cannot do anything about it. - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + // @ts-expect-error This is unsafe, but we cannot do anything about it. predicate: (mutation) => mutation.state.variables?.id === project.id, }) .forEach((mutation) => { diff --git a/app/gui/src/dashboard/hooks/scrollHooks.ts b/app/gui/src/dashboard/hooks/scrollHooks.ts index b30a9816eea6..83f73828f667 100644 --- a/app/gui/src/dashboard/hooks/scrollHooks.ts +++ b/app/gui/src/dashboard/hooks/scrollHooks.ts @@ -1,5 +1,5 @@ /** @file Execute a function on scroll. */ -import { useState, type MutableRefObject, type RefObject } from 'react' +import { useState, type RefObject } from 'react' import { useSyncRef } from '#/hooks/syncRefHooks' import useOnScroll from '#/hooks/useOnScroll' @@ -20,8 +20,8 @@ interface UseStickyTableHeaderOnScrollOptions { * @param bodyRef - a {@link useRef} to the `tbody` element that needs to be clipped. */ export function useStickyTableHeaderOnScroll( - rootRef: MutableRefObject, - bodyRef: RefObject, + rootRef: RefObject, + bodyRef: RefObject, options: UseStickyTableHeaderOnScrollOptions = {}, ) { const { trackShadowClass = false } = options diff --git a/app/gui/src/dashboard/hooks/storeHooks.ts b/app/gui/src/dashboard/hooks/storeHooks.ts index 229b8034ebff..1d05c75afb16 100644 --- a/app/gui/src/dashboard/hooks/storeHooks.ts +++ b/app/gui/src/dashboard/hooks/storeHooks.ts @@ -3,7 +3,7 @@ * * This file contains hooks for using Zustand store with tearing transitions. */ -import type { DispatchWithoutAction, Reducer, RefObject } from 'react' +import type { DispatchWithoutAction, RefObject } from 'react' import { useEffect, useReducer, useRef } from 'react' import { type StoreApi } from 'zustand' import { useStoreWithEqualityFn } from 'zustand/traditional' @@ -83,17 +83,13 @@ export function useTearingTransitionStore( const equalityFunction = resolveAreEqual(areEqual) - const [[sliceFromReducer, storeFromReducer], rerender] = useReducer< - Reducer< - readonly [Slice, StoreApi, State], - readonly [Slice, StoreApi, State] | undefined - >, - undefined - >( + const [[sliceFromReducer, storeFromReducer], rerender] = useReducer( (prev, fromSelf) => { - if (fromSelf) { - return fromSelf + if (fromSelf != null) { + // eslint-disable-next-line no-restricted-syntax + return fromSelf as [Slice, StoreApi, State] } + const nextState = store.getState() if (Object.is(prev[2], nextState) && prev[1] === store) { return prev @@ -104,10 +100,10 @@ export function useTearingTransitionStore( return prev } - return [nextSlice, store, nextState] + return [nextSlice, store, nextState] as const }, undefined, - () => [selector(state), store, state], + () => [selector(state), store, state] as const, ) useEffect(() => { @@ -151,7 +147,7 @@ function useNonCompilableConditionalStore( equalityFunction: EqualityFunction, prevUnsafeEnableTransition: RefObject, ) { - /* eslint-disable react-compiler/react-compiler */ + // eslint-disable-next-line react-compiler/react-compiler /* eslint-disable react-hooks/rules-of-hooks */ if (prevUnsafeEnableTransition.current !== unsafeEnableTransition) { throw new Error( @@ -161,6 +157,5 @@ function useNonCompilableConditionalStore( return unsafeEnableTransition ? useTearingTransitionStore(store, selector, equalityFunction) : useStoreWithEqualityFn(store, selector, equalityFunction) - /* eslint-enable react-compiler/react-compiler */ /* eslint-enable react-hooks/rules-of-hooks */ } diff --git a/app/gui/src/dashboard/layouts/AssetContextMenu.tsx b/app/gui/src/dashboard/layouts/AssetContextMenu.tsx index 89dbb4865b83..5147b0f74a0e 100644 --- a/app/gui/src/dashboard/layouts/AssetContextMenu.tsx +++ b/app/gui/src/dashboard/layouts/AssetContextMenu.tsx @@ -166,7 +166,9 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { hidden={hidden} color="accent" action="copyId" - doAction={() => copyMutation.mutateAsync(asset.id)} + doAction={() => { + void copyMutation.copy(asset.id) + }} /> ) @@ -391,7 +393,9 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {