Files
supabase/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx
Alaister Young 7e9badc6b8 chore(studio): migrate useStaticEffectEvent to React 19 useEffectEvent (#46415)
Studio is on `react@^19.2.6`, and `useEffectEvent` shipped stable in
React 19.2 with the same signature as the userland polyfill. This drops
the local hook in `apps/studio` and `apps/www` in favor of the built-in.

**Removed:**
- `apps/studio/hooks/useStaticEffectEvent.ts`
- `apps/www/hooks/useStaticEffectEvent.ts`
- `.claude/skills/use-static-effect-event/` — skill is obsolete

**Changed:**
- 26 call sites: dropped the `useStaticEffectEvent` import, added
`useEffectEvent` to the existing `react` import, renamed call sites
- `.claude/CLAUDE.md`: `apps/studio` row updated React 18 → React 19
- `.claude/skills/vercel-composition-patterns/SKILL.md`: removed stale
"Studio uses React 18, skip these patterns" warning

## To test

- `pnpm typecheck --filter=studio` — passes locally
- `pnpm typecheck --filter=www` — passes locally
- `grep -rn "useStaticEffectEvent"` returns nothing outside
`node_modules`
- Smoke-test areas that use the hook: schema visualizer edges
(intersection check), spreadsheet import, sign-in/CLI login flows, side
panels with unsaved-changes prompts

**Out of scope:** pre-existing Tailwind lint warning on
`DefaultEdge.tsx:141` (`outline` + `outline-1` conflict) — unrelated to
this migration

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Refactor**
* Internal event handling migrated to React’s built-in event hooks
across the Studio app; no user-facing changes.

* **Documentation**
* Clarified React 19 compatibility and noted Studio now targets React
19.
  * Removed obsolete documentation for a deprecated internal hook.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46415?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-05-28 23:30:42 +08:00

207 lines
6.4 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect, useEffectEvent, useMemo, useState, type ReactNode } from 'react'
import { useForm, type UseFormReturn } from 'react-hook-form'
import { toast } from 'sonner'
import { ApiAuthorizationApprovedScreen } from './ApiAuthorization.Approved'
import { ApiAuthorizationErrorScreen } from './ApiAuthorization.Error'
import { ApiAuthorizationMainView } from './ApiAuthorization.Form'
import { ApiAuthorizationLoadingScreen } from './ApiAuthorization.Loading'
import {
approvalFormSchema,
type ApprovalState,
type IApprovalFormSchema,
} from './ApiAuthorization.Schema'
import { useApiAuthorizationApproveMutation } from '@/data/api-authorization/api-authorization-approve-mutation'
import { useApiAuthorizationDeclineMutation } from '@/data/api-authorization/api-authorization-decline-mutation'
import { useApiAuthorizationQuery } from '@/data/api-authorization/api-authorization-query'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import type { Organization } from '@/types'
function getMatchingOrganization(
organization_slug: string | undefined,
organizations: Array<Organization> | undefined
): Organization | null {
if (!organization_slug || !organizations) return null
return organizations.find(({ slug }) => slug === organization_slug) ?? null
}
interface PreselectOrganizationSlugParameters {
form: UseFormReturn<IApprovalFormSchema>
organization_slug: string | undefined
organizations: Array<{ slug: string }>
}
function preselectOrganizationSlug({
form,
organization_slug,
organizations,
}: PreselectOrganizationSlugParameters) {
if (organization_slug) {
const preselected = organizations.find(({ slug }) => slug === organization_slug)
if (preselected) form.setValue('selectedOrgSlug', preselected.slug)
} else if (!form.getValues('selectedOrgSlug') && organizations.length === 1) {
form.setValue('selectedOrgSlug', organizations[0].slug)
}
}
function useOrganizationsState(organization_slug: string | undefined) {
const {
data: organizations,
isPending: isLoadingOrganizations,
isError: isErrorOrganizations,
error: organizationsError,
} = useOrganizationsQuery()
const organizationsState = useMemo(
function calculateOrganizationsState() {
if (isLoadingOrganizations) {
return { _tag: 'loading' as const }
}
if (isErrorOrganizations) {
return { _tag: 'error' as const, error: organizationsError }
}
if (organizations.length === 0) {
return { _tag: 'empty' as const }
}
if (organization_slug) {
const matchingOrganization = getMatchingOrganization(organization_slug, organizations)
if (!matchingOrganization) {
return { _tag: 'not_member' as const }
}
}
return { _tag: 'success' as const, organizations }
},
[
isLoadingOrganizations,
isErrorOrganizations,
organizationsError,
organizations,
organization_slug,
]
)
return organizationsState
}
function usePrefillFormOnOrganizationsSuccess(
form: UseFormReturn<IApprovalFormSchema>,
organizationsState: ReturnType<typeof useOrganizationsState>,
organization_slug: string | undefined
) {
const prefillForm = useEffectEvent(() => {
if (organizationsState._tag === 'success') {
preselectOrganizationSlug({
form,
organization_slug,
organizations: organizationsState.organizations,
})
}
})
useEffect(() => {
if (organizationsState._tag === 'success') {
prefillForm()
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- useEffectEvent fn intentionally not a dep (eslint-plugin-react-hooks v5 doesn't recognize stable useEffectEvent yet)
}, [organizationsState._tag])
}
export interface ApiAuthorizationValidScreenProps {
auth_id: string
organization_slug: string | undefined
navigate: (destination: string) => void
}
export function ApiAuthorizationValidScreen({
auth_id,
organization_slug,
navigate,
}: ApiAuthorizationValidScreenProps): ReactNode {
const [approvalState, setApprovalState] = useState<ApprovalState>('indeterminate')
const form = useForm<IApprovalFormSchema>({
resolver: zodResolver(approvalFormSchema),
defaultValues: { selectedOrgSlug: '' },
mode: 'onSubmit',
reValidateMode: 'onBlur',
})
const organizationsState = useOrganizationsState(organization_slug)
usePrefillFormOnOrganizationsSuccess(form, organizationsState, organization_slug)
const {
data: requester,
isPending: isLoading,
isError,
error,
} = useApiAuthorizationQuery({ id: auth_id })
const isApproved = (requester?.approved_at ?? null) !== null
const { mutate: approveRequest } = useApiAuthorizationApproveMutation({
onSuccess: (res) => {
window.location.href = res.url
},
})
const { mutate: declineRequest } = useApiAuthorizationDeclineMutation({
onSuccess: () => {
toast.success('Declined API authorization request')
navigate('/organizations')
},
})
const onApproveRequest = form.handleSubmit((values) => {
if (approvalState !== 'indeterminate') {
return
}
setApprovalState('approving')
approveRequest(
{ id: auth_id, slug: values.selectedOrgSlug },
{ onError: () => setApprovalState('indeterminate') }
)
})
const onDeclineRequest = form.handleSubmit((values) => {
if (approvalState !== 'indeterminate') {
return
}
setApprovalState('declining')
declineRequest(
{ id: auth_id, slug: values.selectedOrgSlug },
{ onError: () => setApprovalState('indeterminate') }
)
})
if (isLoading) {
return <ApiAuthorizationLoadingScreen />
}
if (isError) {
return <ApiAuthorizationErrorScreen error={error} />
}
if (isApproved) {
const approvedOrganization =
organizationsState._tag === 'success'
? organizationsState.organizations.find(
(org) => org.slug === requester.approved_organization_slug
)
: undefined
return (
<ApiAuthorizationApprovedScreen requester={requester} organization={approvedOrganization} />
)
}
return (
<ApiAuthorizationMainView
approvalState={approvalState}
form={form}
requester={requester}
requestedOrganizationSlug={organization_slug}
organizations={organizationsState}
onApprove={onApproveRequest}
onDecline={onDeclineRequest}
/>
)
}