mirror of
https://github.com/supabase/supabase.git
synced 2026-06-20 22:06:04 +08:00
Four React-19-sensitive patterns that pass on React 18 today but break
under React 19 (verified on the in-flight TanStack Start branch).
Landing on master now so the eventual React 19 upgrade is a no-op for
tests, instead of a separate cleanup pass under upgrade pressure.
Each fix is a strict superset / less-fragile equivalent of the existing
pattern, so master (React 18) stays green.
**Changed:**
- `hooks/misc/useStateTransition.ts` — fire on entry into `newTest` from
any state other than `newTest`, instead of requiring exactly `prevTest →
newTest`. React 18+ auto-batches dispatches across awaits (e.g.
`dispatch SUBMIT` in the handler, `dispatch ERROR` in `onError`),
collapsing `editing → submitting → error` into a single render where the
intermediate `submitting` tick is never observed. Strict superset of the
old check for our reducers — `success`/`error` are only reachable from
`submitting`.
- `Support/CategoryAndSeverityInfo.tsx` — guard `onValueChange` against
Radix Select's spurious `''` emission. When the controlled value
transitions from `undefined` to a defined value whose `SelectItem` isn't
mounted yet (dropdown closed → items haven't registered), Radix's hidden
`BubbleSelect` fires `onValueChange('')` and clobbers the field. No
`SelectItem` can have `value=""` (Radix throws), so any `''` is
guaranteed spurious — drop it before calling `field.onChange`.
([radix-ui/primitives#3381](https://github.com/radix-ui/primitives/issues/3381))
- `EditSecretModal.test.tsx` — `getByLabelText` → `findByLabelText`.
Under React 19's scheduling, the decrypted-value query resolves on a
separate render tick, so form fields appear one tick after the skeleton.
- `LogsPreviewer.test.tsx` — `addEventListener('click', spy)` instead of
`loadOlder.onclick = vi.fn()`. React 19 reassigns `.onclick` on managed
elements as part of its event wiring, clobbering the direct-property
spy.
## To test
### Unit tests
- `pnpm --filter studio test` — all unit tests pass on master (React 18)
### Support form URL prefill (Radix Select guard)
- `/support/new?category=Problem` → category dropdown reads "APIs and
client libraries" on first paint
- `/support/new?category=dashboard_bug` → "Dashboard bug"
(case-insensitive match)
- `/support/new?category=invalid_garbage` → falls back to "Select an
issue" placeholder, no crash
- `/support/new?subject=My%20issue&message=Details%20here` → subject and
message inputs are prefilled
- `/support/new?projectRef=<your-ref>&category=Problem` → both project
selector and category set, library selector appears
- With a prefilled URL, click the category dropdown and pick a different
option — the new value sticks (this is the path that surfaced the Radix
bug, want to confirm we didn't break user selection)
- DevTools console on first load should be clean — no React hydration
mismatch warning
### Support form submit (`useStateTransition` success + error branches)
- Submit a valid support form → green toast "Support request sent"
appears **once**, view swaps to the success screen, one `POST
/platform/feedback/send` in the network panel
- Block `POST /platform/feedback/send` in DevTools → submit → red error
toast appears **once** (not twice — if you see two toasts the relaxed
transition is firing more than it should), form stays editable with all
inputs preserved
- Unblock and submit again → success path runs cleanly
### Sidebar support form (same reducer + `useStateTransition`, separate
component)
- Open the support widget in the side nav (`SupportSidebarForm`)
- Repeat the success and error paths — should behave identically
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Fixed category selector to prevent selected values from being
unexpectedly cleared during form interactions.
* **Tests**
* Improved test reliability for modal field rendering and event handling
assertions.
* **Chores**
* Clarified internal comments for form initialization logic.
[](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45784)
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
145 lines
4.8 KiB
TypeScript
145 lines
4.8 KiB
TypeScript
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { useEffect, useRef, useState, type Dispatch } from 'react'
|
|
import { useForm, useWatch, type DefaultValues, type UseFormReturn } from 'react-hook-form'
|
|
|
|
import { SupportFormSchema, type SupportFormValues } from './SupportForm.schema'
|
|
import type { SupportFormActions } from './SupportForm.state'
|
|
import {
|
|
loadSupportFormInitialParams,
|
|
loadSupportFormInitialParamsFromObject,
|
|
NO_ORG_MARKER,
|
|
NO_PROJECT_MARKER,
|
|
selectInitialOrgAndProject,
|
|
type SupportFormUrlKeys,
|
|
} from './SupportForm.utils'
|
|
// End of third-party imports
|
|
|
|
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
|
|
|
|
const supportFormDefaultValues: DefaultValues<SupportFormValues> = {
|
|
organizationSlug: NO_ORG_MARKER,
|
|
projectRef: NO_PROJECT_MARKER,
|
|
severity: 'Low',
|
|
category: undefined,
|
|
library: '',
|
|
subject: '',
|
|
message: '',
|
|
affectedServices: '',
|
|
allowSupportAccess: true,
|
|
attachDashboardLogs: true,
|
|
dashboardSentryIssueId: '',
|
|
}
|
|
|
|
interface UseSupportFormResult {
|
|
form: UseFormReturn<SupportFormValues>
|
|
initialError: string | null
|
|
projectRef: string | null
|
|
orgSlug: string | null
|
|
}
|
|
|
|
export function useSupportForm(
|
|
dispatch: Dispatch<SupportFormActions>,
|
|
initialParams?: Partial<SupportFormUrlKeys>
|
|
): UseSupportFormResult {
|
|
const form = useForm<SupportFormValues>({
|
|
mode: 'onBlur',
|
|
reValidateMode: 'onBlur',
|
|
resolver: zodResolver(SupportFormSchema),
|
|
defaultValues: supportFormDefaultValues,
|
|
})
|
|
|
|
const urlParamsRef = useRef<SupportFormUrlKeys | null>(null)
|
|
const providedInitialParamsRef = useRef(initialParams)
|
|
const [initialError, setInitialError] = useState<string | null>(null)
|
|
|
|
// Load initial values from URL params after mount so SSR/SSG render with
|
|
// bare defaults (no `window` access) and the client hydrates against the
|
|
// same HTML. URL-derived values are applied here, post-hydration.
|
|
useEffect(() => {
|
|
const params =
|
|
providedInitialParamsRef.current !== undefined
|
|
? loadSupportFormInitialParamsFromObject(providedInitialParamsRef.current)
|
|
: loadSupportFormInitialParams(window.location.search)
|
|
urlParamsRef.current = params
|
|
setInitialError(params.error ?? null)
|
|
|
|
if (params.category && !form.getFieldState('category').isDirty) {
|
|
form.setValue('category', params.category, { shouldDirty: false })
|
|
}
|
|
if (typeof params.subject === 'string' && !form.getFieldState('subject').isDirty) {
|
|
form.setValue('subject', params.subject, { shouldDirty: false })
|
|
}
|
|
if (typeof params.message === 'string' && !form.getFieldState('message').isDirty) {
|
|
form.setValue('message', params.message, { shouldDirty: false })
|
|
}
|
|
if (params.sid && !form.getFieldState('dashboardSentryIssueId').isDirty) {
|
|
form.setValue('dashboardSentryIssueId', params.sid, {
|
|
shouldDirty: false,
|
|
})
|
|
}
|
|
}, [form])
|
|
|
|
const hasAppliedOrgProjectRef = useRef(false)
|
|
const { data: organizations, isPending: organizationsLoading } = useOrganizationsQuery()
|
|
|
|
// Organization slug and project ref need to be validated after loading from
|
|
// URL params
|
|
useEffect(() => {
|
|
if (hasAppliedOrgProjectRef.current) return
|
|
if (!urlParamsRef.current) return
|
|
if (organizationsLoading) return
|
|
|
|
hasAppliedOrgProjectRef.current = true
|
|
|
|
const orgSlugFromUrl =
|
|
urlParamsRef.current.orgSlug && urlParamsRef.current.orgSlug !== NO_ORG_MARKER
|
|
? urlParamsRef.current.orgSlug
|
|
: null
|
|
const projectRefFromUrl = urlParamsRef.current.projectRef ?? null
|
|
|
|
selectInitialOrgAndProject({
|
|
projectRef: projectRefFromUrl,
|
|
orgSlug: orgSlugFromUrl,
|
|
orgs: organizations ?? [],
|
|
})
|
|
.then(({ orgSlug, projectRef }) => {
|
|
if (!form.getFieldState('organizationSlug').isDirty) {
|
|
form.setValue('organizationSlug', orgSlug ?? NO_ORG_MARKER, {
|
|
shouldDirty: false,
|
|
})
|
|
}
|
|
if (!form.getFieldState('projectRef').isDirty) {
|
|
form.setValue('projectRef', projectRef ?? NO_PROJECT_MARKER, {
|
|
shouldDirty: false,
|
|
})
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Ignored: fall back to defaults when lookup fails
|
|
})
|
|
.finally(() => {
|
|
dispatch({ type: 'INITIALIZE', debugSource: 'useSupportForm' })
|
|
})
|
|
}, [organizations, organizationsLoading, form, dispatch])
|
|
|
|
const watchedProjectRef = useWatch({
|
|
control: form.control,
|
|
name: 'projectRef',
|
|
})
|
|
const watchedOrgSlug = useWatch({
|
|
control: form.control,
|
|
name: 'organizationSlug',
|
|
})
|
|
|
|
const projectRef =
|
|
watchedProjectRef && watchedProjectRef !== NO_PROJECT_MARKER ? watchedProjectRef : null
|
|
const orgSlug = watchedOrgSlug && watchedOrgSlug !== NO_ORG_MARKER ? watchedOrgSlug : null
|
|
|
|
return {
|
|
form,
|
|
initialError,
|
|
projectRef,
|
|
orgSlug,
|
|
}
|
|
}
|