diff --git a/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx b/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx index ba4d114d0c4b4..fbdf0c87c493f 100644 --- a/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx +++ b/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx @@ -1,22 +1,22 @@ -import dayjs from 'dayjs' -import { ChevronRight, Loader2 } from 'lucide-react' -import Link from 'next/link' - import { useParams } from 'common' import { InlineLink } from 'components/ui/InlineLink' import { SingleStat } from 'components/ui/SingleStat' import { useBranchesQuery } from 'data/branches/branches-query' import { useEdgeFunctionServiceStatusQuery } from 'data/service-status/edge-functions-status-query' import { useProjectServiceStatusQuery } from 'data/service-status/service-status-query' +import dayjs from 'dayjs' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' -import { InfoIcon, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, cn } from 'ui' +import { ChevronRight, Loader2 } from 'lucide-react' +import Link from 'next/link' +import { cn, InfoIcon, Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_ } from 'ui' + import { + extractDbSchema, ProjectServiceStatus, StatusIcon, StatusMessage, - extractDbSchema, } from '../Home/ServiceStatus' const SERVICE_STATUS_THRESHOLD = 5 // minutes @@ -224,10 +224,11 @@ export const ServiceStatus = () => { currentBranch?.status === 'RUNNING_MIGRATIONS' || isMigrationLoading)) + const isProjectComingUp = ['COMING_UP', 'UNKNOWN'].includes(project?.status ?? '') + const anyUnhealthy = services.some((service) => service.status === 'UNHEALTHY') - const anyComingUp = services.some((service) => service.status === 'COMING_UP') - // Spinner only while the overall project is in COMING_UP; otherwise show 6-dot grid - const showSpinnerIcon = project?.status === 'COMING_UP' + const anyComingUp = + isProjectComingUp || services.some((service) => service.status === 'COMING_UP') const getOverallStatusLabel = (): string => { if (isLoadingChecks) return 'Checking...' @@ -243,7 +244,8 @@ export const ServiceStatus = () => { ) : (
@@ -288,7 +290,11 @@ export const ServiceStatus = () => {

diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 4e57195e7f1a6..dbd847a86b025 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -8,7 +8,6 @@ import * as z from 'zod' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import InformationBox from 'components/ui/InformationBox' import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector' import { UpgradePlanButton } from 'components/ui/UpgradePlanButton' import { useOrganizationCreateInvitationMutation } from 'data/organization-members/organization-invitation-create-mutation' @@ -21,6 +20,14 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization import { DOCS_URL } from 'lib/constants' import { useProfile } from 'lib/profile' import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, Button, Dialog, DialogContent, @@ -29,10 +36,11 @@ import { DialogSectionSeparator, DialogTitle, DialogTrigger, + ExpandingTextArea, FormControl_Shadcn_, FormField_Shadcn_, + DialogFooter, Form_Shadcn_, - Input_Shadcn_, SelectContent_Shadcn_, SelectGroup_Shadcn_, SelectItem_Shadcn_, @@ -42,6 +50,15 @@ import { } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { useGetRolesManagementPermissions } from './TeamSettings.utils' +import { UserPlus } from 'lucide-react' +import { Admonition } from 'ui-patterns' + +function parseEmails(value: string): string[] { + return value + .split(',') + .map((e) => e.trim()) + .filter(Boolean) +} export const InviteMemberButton = () => { const { slug } = useParams() @@ -54,6 +71,7 @@ export const InviteMemberButton = () => { ]) const [isOpen, setIsOpen] = useState(false) + const [isDiscardConfirmOpen, setIsDiscardConfirmOpen] = useState(false) const [projectDropdownOpen, setProjectDropdownOpen] = useState(false) const { data: members } = useOrganizationMembersQuery({ slug }) @@ -61,6 +79,7 @@ export const InviteMemberButton = () => { const orgScopedRoles = allRoles?.org_scoped_roles ?? [] const currentPlan = organization?.plan + const isFreeOrProPlan = currentPlan?.id === 'free' || currentPlan?.id === 'pro' const hasAccessToProjectLevelPermissions = useHasAccessToProjectLevelPermissions(slug as string) const userMemberData = members?.find((m) => m.gotrue_id === profile?.gotrue_id) @@ -87,77 +106,183 @@ export const InviteMemberButton = () => { ) ) - const { mutate: inviteMember, isPending: isInviting } = useOrganizationCreateInvitationMutation() + const { mutateAsync: inviteMemberAsync, isPending: isInviting } = + useOrganizationCreateInvitationMutation() + + const emailSchema = z + .string() + .min(1, 'At least one email address is required') + .refine( + (val) => { + const emails = parseEmails(val) + if (emails.length === 0) return false + return emails.every((e) => z.string().email().safeParse(e).success) + }, + (val) => { + const emails = parseEmails(val) + const invalid = emails.find((e) => !z.string().email().safeParse(e).success) + return { + message: invalid + ? `Invalid email address: ${invalid}` + : 'At least one email address is required', + } + } + ) const FormSchema = z.object({ - email: z.string().email('Must be a valid email address').min(1, 'Email is required'), + email: emailSchema, role: z.string().min(1, 'Role is required'), applyToOrg: z.boolean(), projectRef: z.string(), }) const form = useForm>({ - mode: 'onBlur', - reValidateMode: 'onBlur', + mode: 'onSubmit', + reValidateMode: 'onChange', resolver: zodResolver(FormSchema), defaultValues: { email: '', role: '', applyToOrg: true, projectRef: '' }, }) - const { applyToOrg, projectRef } = form.watch() + const { applyToOrg, projectRef, email } = form.watch() + + const emailCount = parseEmails(email ?? '').length const onInviteMember = async (values: z.infer) => { if (!slug) return console.error('Slug is required') if (profile?.id === undefined) return console.error('Profile ID required') const developerRole = orgScopedRoles.find((role) => role.name === 'Developer') - const existingMember = (members ?? []).find( - (member) => member.primary_email === values.email.toLowerCase() - ) - if (existingMember !== undefined) { - if (existingMember.invited_id) { - return toast('User has already been invited to this organization') + const emails = parseEmails(values.email).map((e) => e.toLowerCase()) + + const alreadyInvited: string[] = [] + const alreadyMembers: string[] = [] + const toInvite: string[] = [] + + for (const emailAddress of emails) { + const existingMember = (members ?? []).find((member) => member.primary_email === emailAddress) + if (existingMember !== undefined) { + if (existingMember.invited_id) { + alreadyInvited.push(emailAddress) + } else { + alreadyMembers.push(emailAddress) + } } else { - return toast('User is already in this organization') + toInvite.push(emailAddress) } } - inviteMember( - { - slug, - email: values.email.toLowerCase(), - roleId: Number(values.role), - ...(!values.applyToOrg && values.projectRef ? { projects: [values.projectRef] } : {}), - }, - { - onSuccess: () => { - toast.success('Successfully sent invitation to new member') - setIsOpen(!isOpen) + if (alreadyInvited.length > 0) { + toast.error( + alreadyInvited.length === 1 + ? `${alreadyInvited[0]} has already been invited to this organization` + : `${alreadyInvited.length} emails have already been invited to this organization` + ) + } + if (alreadyMembers.length > 0) { + toast.error( + alreadyMembers.length === 1 + ? `${alreadyMembers[0]} is already in this organization` + : `${alreadyMembers.length} emails are already in this organization` + ) + } + if (alreadyInvited.length > 0 || alreadyMembers.length > 0) { + if (toInvite.length === 0) return + } - form.reset({ - email: '', - role: developerRole?.id.toString() ?? '', - applyToOrg: true, - projectRef: '', - }) - }, - } + const projectPayload = + !values.applyToOrg && values.projectRef ? { projects: [values.projectRef] } : {} + const results = await Promise.allSettled( + toInvite.map((emailAddress) => + inviteMemberAsync({ + slug, + email: emailAddress, + roleId: Number(values.role), + ...projectPayload, + }) + ) ) + + const successCount = results.filter((r) => r.status === 'fulfilled').length + const failedEmails = toInvite.filter((_, i) => results[i].status === 'rejected') + + if (successCount > 0) { + toast.success( + successCount === 1 + ? 'Successfully sent invitation to new member' + : `Successfully sent invitations to ${successCount} new members` + ) + setIsOpen(false) + form.reset({ + email: '', + role: developerRole?.id.toString() ?? '', + applyToOrg: true, + projectRef: '', + }) + } + if (failedEmails.length > 0) { + toast.error( + failedEmails.length === 1 + ? `Failed to send invitation to ${failedEmails[0]}` + : `Failed to send invitations to ${failedEmails.length} emails` + ) + } } useEffect(() => { if (isSuccess && isOpen) { const developerRole = orgScopedRoles.find((role) => role.name === 'Developer') - if (developerRole !== undefined) form.setValue('role', developerRole.id.toString()) + if (developerRole !== undefined) { + form.reset({ + ...form.getValues(), + role: developerRole.id.toString(), + }) + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSuccess, isOpen]) + const hasUnsavedChanges = form.formState.isDirty + + const handleOpenChange = (open: boolean) => { + if (!open && hasUnsavedChanges) { + setIsDiscardConfirmOpen(true) + } else { + setIsOpen(open) + } + } + + const handleCancel = () => { + if (hasUnsavedChanges) { + setIsDiscardConfirmOpen(true) + } else { + form.reset({ + email: '', + role: '', + applyToOrg: true, + projectRef: '', + }) + setIsOpen(false) + } + } + + const handleDiscardConfirm = () => { + form.reset({ + email: '', + role: '', + applyToOrg: true, + projectRef: '', + }) + setIsDiscardConfirmOpen(false) + setIsOpen(false) + } + return ( - + } className="pointer-events-auto flex-grow md:flex-grow-0" onClick={() => setIsOpen(true)} tooltip={{ @@ -166,19 +291,43 @@ export const InviteMemberButton = () => { text: !organizationMembersCreationEnabled ? 'Inviting members is currently disabled' : !canInviteMembers - ? 'You need additional permissions to invite a member to this organization' + ? 'You need additional permissions to invite members to this organization' : undefined, }, }} > - Invite member + Invite members - Invite a member to this organization + Invite team members + + + {isFreeOrProPlan ? ( + + ) : null} + + } + />
{ onSubmit={form.handleSubmit(onInviteMember)} > - {hasAccessToProjectLevelPermissions && ( - ( - - - form.setValue('applyToOrg', value)} - /> - - - )} - /> - )} ( - + { )} /> + {hasAccessToProjectLevelPermissions && ( + ( + + + form.setValue('applyToOrg', value)} + /> + + + )} + /> + )} {!applyToOrg && ( { render={({ field }) => ( { name="email" control={form.control} render={({ field }) => ( - + - )} /> - -

- Supabase offers single sign-on (SSO) as a login option to provide additional - account security for your team. This allows company administrators to enforce - the use of an identity provider when logging into Supabase. -

-

This is only available for organizations on Team Plan or above.

-
- - {(currentPlan?.id === 'free' || currentPlan?.id === 'pro') && ( - - )} -
- - } - />
- - - - + +
+ + + + Discard changes? + + Are you sure you want to discard your changes? Your invitation will not be sent. + + + + Keep editing + + Discard changes + + + +
) } diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx index 5b9c425d55f94..5daac9c1745ed 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx @@ -147,7 +147,7 @@ export const MemberActions = ({ member }: MemberActionsProps) => { deleteInvitation( { slug, id: invitedId }, - { onSuccess: () => toast.success('Successfully revoked the invitation.') } + { onSuccess: () => toast.success('Successfully canceled the invitation') } ) } @@ -170,7 +170,7 @@ export const MemberActions = ({ member }: MemberActionsProps) => { content: { side: 'bottom', text: isPendingInviteAcceptance - ? 'Role can only be changed after the user has accepted the invite' + ? 'Access can be managed after the invite is accepted' : !canRemoveMember ? 'You need additional permissions to manage this team member' : undefined, diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx index 5dea2402437de..083253d99fc1f 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx @@ -1,4 +1,4 @@ -import { ArrowRight, Check, User, X } from 'lucide-react' +import { ArrowRight, Check, User, X, ChevronRight } from 'lucide-react' import Link from 'next/link' import { useMemo } from 'react' @@ -72,40 +72,44 @@ export const MemberRow = ({ member }: MemberRowProps) => { } /> -
+

{member.primary_email}

- {member.gotrue_id === profile?.gotrue_id && You} +
+ {member.gotrue_id === profile?.gotrue_id && You} + {isInvitedUser && member.invited_at && ( + + {isInviteExpired(member.invited_at) ? 'Expired' : 'Invited'} + + )} + {member.is_sso_user && SSO} + {(member.metadata as any)?.origin && ( + + )} +
- - {(member.metadata as any)?.origin && ( - - )}
- {isInvitedUser && member.invited_at && ( - - {isInviteExpired(member.invited_at) ? 'Expired' : 'Invited'} - - )} - {member.is_sso_user && SSO} - - - -
+
{member.mfa_enabled ? ( - + <> + Enabled + + ) : ( - + <> + Disabled + + )}
@@ -130,18 +134,18 @@ export const MemberRow = ({ member }: MemberRowProps) => { return (
-

{roleName}

+

{roleName}

{hasProjectScopedRoles && ( <> - + {projectsApplied.length === 1 ? ( - + {projectsApplied[0]} ) : ( - + {role?.projects.length === 0 ? 'Organization' : `${projectsApplied.length} project${projectsApplied.length > 1 ? 's' : ''}`} diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx index 97042788b4c0c..3ccf0d018b0dd 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx @@ -15,6 +15,7 @@ import { Table, TableBody, TableCell, + TableFooter, TableHead, TableHeader, TableRow, @@ -62,6 +63,7 @@ const MembersView = ({ searchString }: MembersViewProps) => { member.username.includes(searchString) || member.primary_email?.includes(searchString) ) } + return false }) }, [members, searchString]) @@ -102,30 +104,9 @@ const MembersView = ({ searchString }: MembersViewProps) => { - User - - - Enabled MFA - - - Role - - - - - - How to configure access control? - - - + Member + MFA + Role @@ -138,9 +119,9 @@ const MembersView = ({ searchString }: MembersViewProps) => { , @@ -157,23 +138,24 @@ const MembersView = ({ searchString }: MembersViewProps) => {

- No users matched the search query "{searchString}" + No members matched the search query "{searchString}"

, ] : []), - - -

- {searchString ? `${filteredMembers.length} of ` : ''} - {members.length || '0'} {members.length == 1 ? 'user' : 'users'} -

-
-
, ]} + + + + {searchString + ? `${filteredMembers.length} of ${members.length} ${members.length === 1 ? 'member' : 'members'}` + : `${members.length || 0} ${members.length === 1 ? 'member' : 'members'}`} + + +
diff --git a/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx b/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx index 1b4787d0600f3..f2c49386fefc5 100644 --- a/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx +++ b/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx @@ -12,10 +12,8 @@ export const AdvisorButton = ({ projectRef }: { projectRef?: string }) => { const { toggleSidebar, activeSidebar } = useSidebarManagerSnapshot() const { data: lints } = useProjectLintsQuery({ projectRef }) - const hasCriticalIssues = Array.isArray(lints) && lints.some((lint) => lint.level === 'ERROR') const { data: notificationsData } = useNotificationsV2Query({ - status: 'new', filters: {}, limit: 20, }) @@ -23,6 +21,11 @@ export const AdvisorButton = ({ projectRef }: { projectRef?: string }) => { return notificationsData?.pages.flatMap((page) => page) ?? [] }, [notificationsData?.pages]) const hasUnreadNotifications = notifications.some((x) => x.status === 'new') + const hasCriticalNotifications = notifications.some((x) => x.priority === 'Critical') + + const hasCriticalIssues = + hasCriticalNotifications || + (Array.isArray(lints) && lints.some((lint) => lint.level === 'ERROR')) const isOpen = activeSidebar?.id === SIDEBAR_KEYS.ADVISOR_PANEL diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx index a12326e244782..3b7b80860d387 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx @@ -1,10 +1,10 @@ -import { AlertTriangle, ChevronRight, Inbox } from 'lucide-react' - import { Lint } from 'data/lint/lint-query' import { Notification } from 'data/notifications/notifications-v2-query' +import { AlertTriangle, ChevronRight, Inbox } from 'lucide-react' import { AdvisorSeverity, AdvisorTab } from 'state/advisor-state' import { Badge, Button, cn } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns' + import type { AdvisorItem } from './AdvisorPanel.types' import { formatItemDate, @@ -57,7 +57,7 @@ export const AdvisorPanelBody = ({ hasProjectRef = true, }: AdvisorPanelBodyProps) => { // Show notice if no project ref and trying to view project-specific tabs - if (!hasProjectRef && activeTab !== 'messages') { + if (!hasProjectRef && activeTab !== 'messages' && activeTab !== 'all') { return } diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx index 4e98579275de5..fa1a9590e69e0 100644 --- a/apps/studio/state/storage-explorer.tsx +++ b/apps/studio/state/storage-explorer.tsx @@ -62,7 +62,8 @@ type UploadProgress = { const LIMIT = 200 const OFFSET = 0 -const BATCH_SIZE = 2 +const DEFAULT_RETRY_SECONDS = 5 +const RATE_LIMIT_RETRY_SECONDS = 60 const DEFAULT_PREFERENCES = { view: STORAGE_VIEWS.COLUMNS, @@ -660,10 +661,10 @@ function createStorageExplorerState({ const files = await state.getAllItemsAlongFolder(folder) let progress = 0 - let hasErrors = false + let failedFiles = 0 + let retrySeconds = DEFAULT_RETRY_SECONDS - // Make this batched promises into a reusable function for storage, i think this will be super helpful - const promises = files.map((file) => { + for (const file of files) { const fromPath = `${file.prefix}/${file.name}` const pathSegments = fromPath.split('/') const toPath = pathSegments @@ -671,50 +672,86 @@ function createStorageExplorerState({ .concat(newName) .concat(pathSegments.slice(columnIndex + 1)) .join('/') - return () => { - return new Promise(async (resolve) => { - progress = progress + 1 / files.length - try { - await moveStorageObject({ - projectRef: state.projectRef, - bucketId: state.selectedBucket.id, - from: fromPath, - to: toPath, + + let success = false + let isRateLimited = false + + for (let attempt = 0; attempt < 3 && !success; attempt++) { + try { + if (attempt > 0) { + await new Promise((resolve) => { + let seconds = retrySeconds + const interval = setInterval(() => { + toast( + , + { id: toastId, closeButton: false, position: 'top-right', duration: Infinity } + ) + + seconds-- + if (seconds <= 0) { + clearInterval(interval) + resolve() + } + }, 1000) }) - } catch (error) { - hasErrors = true - toast.error(`Failed to move ${fromPath} to the new folder`) } - resolve() - }) - } - }) - const batchedPromises = chunk(promises, BATCH_SIZE) - // [Joshen] I realised this can be simplified with just a vanilla for loop, no need for reduce - // Just take note, but if it's working fine, then it's okay + await moveStorageObject({ + projectRef: state.projectRef, + bucketId: state.selectedBucket.id, + from: fromPath, + to: toPath, + }) + success = true + } catch (error) { + if ((error as ResponseError).code === 429) { + isRateLimited = true + retrySeconds = RATE_LIMIT_RETRY_SECONDS + } else { + isRateLimited = false + retrySeconds = DEFAULT_RETRY_SECONDS + } - await batchedPromises.reduce(async (previousPromise, nextBatch) => { - await previousPromise - await Promise.all(nextBatch.map((batch) => batch())) + if (attempt === 2) failedFiles += 1 + } + } + + progress += 1 / files.length toast( - , - { id: toastId, closeButton: false, position: 'top-right' } + , + { id: toastId, closeButton: false, position: 'top-right', duration: Infinity } ) - }, Promise.resolve()) + } - if (!hasErrors) { + if (failedFiles === 0) { toast.success(`Successfully renamed folder to ${newName}`, { id: toastId, closeButton: true, duration: SONNER_DEFAULT_DURATION, }) } else { - toast.error(`Renamed folder to ${newName} with some errors`, { - id: toastId, - closeButton: true, - duration: SONNER_DEFAULT_DURATION, - }) + toast.error( +
+

+ Renamed folder to {newName} with {failedFiles} error{failedFiles > 1 ? 's' : ''} +

+

+ You may try again to rename the folder {originalName} to {newName} +

+
, + { + id: toastId, + closeButton: true, + duration: Infinity, + } + ) } if (state.openedFolders[columnIndex]?.name === folder.name) { diff --git a/packages/ui/src/components/ExpandingTextArea/index.tsx b/packages/ui/src/components/ExpandingTextArea/index.tsx index 03abc44338dcb..48b8755dd1ff2 100644 --- a/packages/ui/src/components/ExpandingTextArea/index.tsx +++ b/packages/ui/src/components/ExpandingTextArea/index.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { forwardRef, useImperativeHandle, useRef } from 'react' +import React, { forwardRef, useImperativeHandle, useLayoutEffect, useRef } from 'react' import { cn } from '../../lib/utils' import { TextArea } from '../shadcn/ui/text-area' @@ -22,16 +22,17 @@ const ExpandingTextArea = forwardRef { if (!element) return - // Update the height - if (!value) { - element.style.height = 'auto' - element.style.minHeight = '36px' - } else { - element.style.height = 'auto' - element.style.height = element.scrollHeight + 'px' - } + // Match single-line input height (h-10 = 40px) so we don't shrink when typing; grow only when content wraps + const singleLineHeightPx = 40 + element.style.height = 'auto' + const contentHeight = element.scrollHeight + element.style.height = Math.max(singleLineHeightPx, contentHeight) + 'px' } + useLayoutEffect(() => { + updateTextAreaHeight(internalRef.current) + }, [value]) + return (