feat(studio): flag for unavailable regions (#50473)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Adds feature flag for controlling region unavailability.


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

* **New Features**
* Region options now display availability badges, tooltips, and
status-specific notices.
* Restricted regions remain selectable so users can review their
availability status.
* Project creation provides a clear field-level message when a selected
region is unavailable and prompts users to choose another region.

* **Bug Fixes**
* Region availability messaging now consistently reflects platform
status and configured restrictions.
  * Availability warnings clear after selecting an eligible region.
  * Region checks now cover both dynamic and static provider regions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
kemal.earth authored and GitHub committed 2026-09-17 14:42:58 +01:00
1 parent 7b4e3aba01
commit 24e8333c54
6 files changed
+383 -10

No files matched your search

@@ -30,6 +30,7 @@ import {
} from './ProjectCreation.constants'
import { FormSchema } from './ProjectCreation.schema'
import {
getAvailableRegions,
getHighAvailabilityRegionCode,
instanceLabel,
monthlyInstancePrice,
@@ -39,7 +40,9 @@ import {
import { ProjectCreationFooter } from './ProjectCreationFooter'
import { ProjectNameInput } from './ProjectNameInput'
import { RegionSelector } from './RegionSelector'
import { getRegionRestrictionMessage } from './RegionSelector.utils'
import { SecurityOptions } from './SecurityOptions'
import { useRegionRestriction } from './useRegionRestriction'
import { AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL } from '@/components/interfaces/Database/Triggers/EventTriggersList/EventTriggers.constants'
import {
GitHubRepositoryField,
@@ -136,6 +139,7 @@ export const ProjectCreationForm = ({
const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate')
const showInternalOnlyConfiguration =
useFlag('newProjectInternalOnlyConfiguration') && !isVercelIntegrationFlow
const { getRegionRestriction } = useRegionRestriction()
// Read the raw flag for telemetry — coerce-undefined-to-false would record false for
// users whose flags haven't loaded yet. The raw value preserves undefined (omitted from
@@ -181,7 +185,7 @@ export const ProjectCreationForm = ({
shouldRunMigrations: true,
},
})
const { getFieldState, resetField, setValue } = form
const { getFieldState, resetField, setError, setValue } = form
const {
instanceSize: watchedInstanceSize,
cloudProvider,
@@ -470,6 +474,29 @@ export const ProjectCreationForm = ({
`High Availability projects are not available in the required region (${highAvailabilityRegionCode})`
)
}
const selectedSpecificRegion = specific.find((x) => x.name === dbRegion)
const selectedStaticRegion = smartRegionEnabled
? undefined
: Object.values(getAvailableRegions(cloudProvider as CloudProvider)).find(
(region) => region.displayName === dbRegion
)
const selectedRegionRestriction = getRegionRestriction(
selectedSpecificRegion ?? selectedStaticRegion
)
if (selectedRegionRestriction !== undefined) {
setError(
'dbRegion',
{ type: 'manual', message: getRegionRestrictionMessage(selectedRegionRestriction) },
{ shouldFocus: true }
)
trackFunnelError(
'project_creation',
{ errorCategory: 'validation', errorReason: 'region_unavailable' },
'form'
)
return
}
const parsedGitHubRepositoryId =
githubRepositoryId.length > 0 ? Number(githubRepositoryId) : undefined
const shouldIncludeGitHubFields =
@@ -30,6 +30,8 @@ import {
getAvailableRegions,
getHighAvailabilityRegionCode,
} from './ProjectCreation.utils'
import { getRegionRestrictionCopy, SELECT_DIFFERENT_REGION } from './RegionSelector.utils'
import { useRegionRestriction } from './useRegionRestriction'
import { AlertError } from '@/components/ui/AlertError'
import { InlineLink } from '@/components/ui/InlineLink'
import Panel from '@/components/ui/Panel'
@@ -85,6 +87,8 @@ export const RegionSelector = ({
const { hasLoaded: flagsLoaded } = useFeatureFlags()
const smartRegionEnabled = cloudProvider !== 'AWS_NIMBUS'
const { getRegionRestriction } = useRegionRestriction()
const { data: statusData } = useIncidentStatusQuery()
const { incidents = [] } = statusData ?? {}
@@ -131,6 +135,10 @@ export const RegionSelector = ({
[...unfilteredRegionOptions],
highAvailability
)
const regionOptionsWithRestriction = regionOptions.map((region) => ({
...region,
restriction: getRegionRestriction(region),
}))
const isLoading = smartRegionEnabled ? isLoadingAvailableRegions : isLoadingDefaultRegion
const isLocalEnvironment = process.env.NEXT_PUBLIC_ENVIRONMENT === 'local'
@@ -181,6 +189,13 @@ export const RegionSelector = ({
const selectedRegionLabel = selectedRegion?.name
? getDisplayNameForSmartRegion(selectedRegion.name)
: dbRegion
const selectedRestriction = regionOptionsWithRestriction.find(
(region) => region.name === dbRegion
)?.restriction
const selectedRestrictionCopy =
selectedRestriction !== undefined
? getRegionRestrictionCopy(selectedRestriction)
: undefined
const triggerLabel = isLoading ? 'Loading available regions...' : selectedRegionLabel
const affectingIncidents = incidents.filter((incident) => {
@@ -237,6 +252,7 @@ export const RegionSelector = ({
disabled={isLoading}
>
<SelectTrigger
ref={field.ref}
id="region"
className="[&>:nth-child(1)]:w-full [&>:nth-child(1)]:flex [&>:nth-child(1)]:items-start"
>
@@ -298,16 +314,19 @@ export const RegionSelector = ({
<SelectLabel>
{highAvailability ? 'High Availability Regions' : 'Specific regions'}
</SelectLabel>
{regionOptions.map((value) => {
{regionOptionsWithRestriction.map((value) => {
const restrictionCopy =
value.restriction !== undefined
? getRegionRestrictionCopy(value.restriction)
: undefined
return (
<SelectItem
key={value.code}
value={value.name}
className={cn(
'w-full [&>:nth-child(2)]:w-full',
value.status !== undefined && 'pointer-events-auto!'
restrictionCopy !== undefined && 'pointer-events-auto!'
)}
disabled={value.status !== undefined}
>
<div className="flex flex-row items-center justify-between w-full gap-x-2">
<div className="flex items-center gap-x-3">
@@ -325,16 +344,14 @@ export const RegionSelector = ({
Recommended
</Badge>
)}
{value.status !== undefined && value.status === 'capacity' && (
{restrictionCopy !== undefined && (
<Tooltip>
<TooltipTrigger>
<Badge variant="warning" className="mr-1">
Unavailable
{restrictionCopy.badge}
</Badge>
</TooltipTrigger>
<TooltipContent>
Temporarily unavailable due to this region being at capacity.
</TooltipContent>
<TooltipContent>{restrictionCopy.tooltip}</TooltipContent>
</Tooltip>
)}
</div>
@@ -366,6 +383,17 @@ export const RegionSelector = ({
/>
</FormItemLayout>
)}
{selectedRestrictionCopy !== undefined && (
<FormItemLayout layout="horizontal" isReactForm={false}>
<Admonition
type="warning"
title={selectedRestrictionCopy.title}
description={`${selectedRestrictionCopy.notice} ${SELECT_DIFFERENT_REGION}`}
className="mt-3"
/>
</FormItemLayout>
)}
</>
)
}}
@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from 'vitest'
import {
getRegionRestrictionCopy,
getRegionRestrictionMessage,
parseRestrictedRegions,
REGION_RESTRICTION_COPY,
resolveRegionRestriction,
SELECT_DIFFERENT_REGION,
} from './RegionSelector.utils'
vi.mock('@/lib/error-reporting', () => ({ captureCriticalError: vi.fn() }))
describe('parseRestrictedRegions', () => {
it.each([
['the false that useFlag returns when unresolved or errored', false],
['undefined', undefined],
['an empty string', ''],
['an empty object', '{}'],
['invalid JSON', 'not json'],
['an unknown status value', '{"sa-east-1":"bogus"}'],
['a status value the flag no longer supports', '{"sa-east-1":"paid_only"}'],
['an array instead of a record', '["sa-east-1"]'],
['a non-string value', 42],
])('fails open to no restrictions for %s', (_label, flagValue) => {
expect(parseRestrictedRegions(flagValue)).toEqual({})
})
it('returns the restrictions keyed by region code for a valid payload', () => {
expect(
parseRestrictedRegions('{"sa-east-1":"unavailable","eu-central-1":"unavailable"}')
).toEqual({ 'sa-east-1': 'unavailable', 'eu-central-1': 'unavailable' })
})
it('drops every restriction when any entry is invalid', () => {
expect(parseRestrictedRegions('{"sa-east-1":"unavailable","eu-central-1":"bogus"}')).toEqual({})
})
it('reports a present-but-invalid payload without reporting the unresolved flag value', async () => {
const { captureCriticalError } = await import('@/lib/error-reporting')
vi.mocked(captureCriticalError).mockClear()
parseRestrictedRegions(false)
parseRestrictedRegions('')
expect(captureCriticalError).not.toHaveBeenCalled()
parseRestrictedRegions('not json')
parseRestrictedRegions('{"sa-east-1":"bogus"}')
expect(captureCriticalError).toHaveBeenCalledTimes(2)
})
})
describe('resolveRegionRestriction', () => {
it('lets the platform status win over the flag', () => {
expect(
resolveRegionRestriction({ platformStatus: 'capacity', flagRestriction: 'unavailable' })
).toBe('capacity')
expect(
resolveRegionRestriction({ platformStatus: 'other', flagRestriction: 'unavailable' })
).toBe('other')
})
it('applies the flag only where the platform reports no status', () => {
expect(
resolveRegionRestriction({ platformStatus: undefined, flagRestriction: 'unavailable' })
).toBe('unavailable')
})
it('passes a platform status it does not recognise through unchanged', () => {
expect(
resolveRegionRestriction({ platformStatus: 'brand_new', flagRestriction: undefined })
).toBe('brand_new')
})
it('returns no restriction when neither source restricts the region', () => {
expect(
resolveRegionRestriction({ platformStatus: undefined, flagRestriction: undefined })
).toBeUndefined()
})
})
describe('getRegionRestrictionCopy', () => {
it('uses the capacity copy for a capacity status', () => {
expect(getRegionRestrictionCopy('capacity')).toBe(REGION_RESTRICTION_COPY.capacity)
expect(getRegionRestrictionCopy('capacity').tooltip).toBe(
'Temporarily unavailable due to this region being at capacity.'
)
})
it.each(['other', 'unavailable', 'brand_new', 'toString'])(
'falls back to the generic copy for %s',
(restriction) => {
expect(getRegionRestrictionCopy(restriction)).toBe(REGION_RESTRICTION_COPY.other)
}
)
it('builds the submit message from the title and the shared call to action', () => {
expect(getRegionRestrictionMessage('capacity')).toBe(
`Selected region is at capacity. ${SELECT_DIFFERENT_REGION}`
)
expect(getRegionRestrictionMessage('brand_new')).toBe(
`Selected region is unavailable. ${SELECT_DIFFERENT_REGION}`
)
})
})
@@ -0,0 +1,95 @@
import { z } from 'zod'
import { captureCriticalError } from '@/lib/error-reporting'
export const RESTRICTED_REGIONS_FLAG_KEY = 'projectCreationRestrictedRegions'
export type PlatformRegionStatus = 'capacity' | 'other'
const FLAG_RESTRICTIONS = ['unavailable'] as const
export type FlagRestriction = (typeof FLAG_RESTRICTIONS)[number]
export type RegionRestriction = PlatformRegionStatus | FlagRestriction
export type RegionRestrictionCopy = {
badge: string
title: string
tooltip: string
notice: string
}
const GENERIC_RESTRICTION_COPY: RegionRestrictionCopy = {
badge: 'Unavailable',
title: 'Selected region is unavailable',
tooltip: 'Temporarily unavailable for new projects.',
notice: 'This region is temporarily unavailable for new projects.',
}
export const REGION_RESTRICTION_COPY: Record<RegionRestriction, RegionRestrictionCopy> = {
capacity: {
badge: 'Unavailable',
title: 'Selected region is at capacity',
tooltip: 'Temporarily unavailable due to this region being at capacity.',
notice:
'This region currently has capacity for Micro compute and above. Free plan projects run on Nano compute.',
},
other: GENERIC_RESTRICTION_COPY,
unavailable: GENERIC_RESTRICTION_COPY,
}
export const SELECT_DIFFERENT_REGION = 'Select a different region to continue.'
function isKnownRestriction(value: string): value is RegionRestriction {
return Object.prototype.hasOwnProperty.call(REGION_RESTRICTION_COPY, value)
}
export function getRegionRestrictionCopy(restriction: string): RegionRestrictionCopy {
return isKnownRestriction(restriction)
? REGION_RESTRICTION_COPY[restriction]
: GENERIC_RESTRICTION_COPY
}
export function getRegionRestrictionMessage(restriction: string) {
return `${getRegionRestrictionCopy(restriction).title}. ${SELECT_DIFFERENT_REGION}`
}
const RestrictedRegionsSchema = z.record(z.string(), z.enum(FLAG_RESTRICTIONS))
export function parseRestrictedRegions(flagValue: unknown): Record<string, FlagRestriction> {
if (typeof flagValue !== 'string' || flagValue.trim() === '') return {}
let payload: unknown
try {
payload = JSON.parse(flagValue)
} catch {
reportInvalidPayload(flagValue, 'invalid JSON')
return {}
}
const result = RestrictedRegionsSchema.safeParse(payload)
if (!result.success) {
reportInvalidPayload(flagValue, result.error.issues[0]?.message ?? 'invalid shape')
return {}
}
return result.data
}
function reportInvalidPayload(flagValue: string, reason: string) {
captureCriticalError(
new Error(`Ignoring ${RESTRICTED_REGIONS_FLAG_KEY} flag (${reason}): ${flagValue}`),
'parse restricted regions flag'
)
}
type ResolveRegionRestrictionArgs = {
platformStatus: string | undefined
flagRestriction: FlagRestriction | undefined
}
export function resolveRegionRestriction({
platformStatus,
flagRestriction,
}: ResolveRegionRestrictionArgs): string | undefined {
return platformStatus ?? flagRestriction
}
@@ -0,0 +1,31 @@
import { useFlag } from 'common'
import { useMemo } from 'react'
import {
parseRestrictedRegions,
resolveRegionRestriction,
RESTRICTED_REGIONS_FLAG_KEY,
} from './RegionSelector.utils'
type RegionLike = {
code: string
status?: string
}
export function useRegionRestriction() {
const restrictedRegionsFlag = useFlag<string | boolean>(RESTRICTED_REGIONS_FLAG_KEY)
const restrictedRegions = useMemo(
() => parseRestrictedRegions(restrictedRegionsFlag),
[restrictedRegionsFlag]
)
const getRegionRestriction = (region: RegionLike | undefined) =>
region === undefined
? undefined
: resolveRegionRestriction({
platformStatus: region.status,
flagRestriction: restrictedRegions[region.code],
})
return { getRegionRestriction }
}
+88 -1
View File
@@ -103,6 +103,18 @@ const DEFAULT_AVAILABLE_REGIONS: RegionsInfo = {
}
const FRANKFURT = 'Central EU (Frankfurt)'
const SAO_PAULO = 'South America (São Paulo)'
const availableRegionsWithSaoPaulo = (status?: 'capacity' | 'other'): RegionsInfo => ({
...DEFAULT_AVAILABLE_REGIONS,
all: {
...DEFAULT_AVAILABLE_REGIONS.all,
specific: [
...DEFAULT_AVAILABLE_REGIONS.all.specific,
{ code: 'sa-east-1', name: SAO_PAULO, provider: 'AWS', type: 'specific', status },
],
},
})
const AVAILABLE_REGIONS_WITH_FRANKFURT: RegionsInfo = {
...DEFAULT_AVAILABLE_REGIONS,
@@ -262,9 +274,10 @@ const DEFAULT_FLAGS = {
newProjectInternalOnlyConfiguration: false,
disableOrioleProjectCreation: false,
defaultRegionRestrictedPool: false,
projectCreationRestrictedRegions: false,
}
async function renderWizard(options: { flags?: Partial<typeof DEFAULT_FLAGS> } = {}) {
async function renderWizard(options: { flags?: Record<string, boolean | string> } = {}) {
const { default: Wizard } = await import('@/pages/new/[slug]')
return customRender(
<FeatureFlagContext.Provider
@@ -479,6 +492,80 @@ describe('project creation wizard', () => {
await screen.findByText('Error loading available regions')
})
describe('restricted regions', () => {
const CAPACITY_COPY = {
title: 'Selected region is at capacity',
notice:
'This region currently has capacity for Micro compute and above. Free plan projects run on Nano compute.',
}
const GENERIC_COPY = {
title: 'Selected region is unavailable',
notice: 'This region is temporarily unavailable for new projects.',
}
const expectSelectableRestrictedRegion = async (
onRequest: ReturnType<typeof vi.fn>,
copy: { title: string; notice: string }
) => {
await fillProjectName('Restricted Region Project')
await generateAndWaitForStrongPassword()
await user.click(getSelectTriggerByLabel('Region'))
const saoPaulo = await screen.findByRole('option', { name: /São Paulo/ })
expect(saoPaulo).not.toHaveAttribute('aria-disabled', 'true')
expect(within(saoPaulo).getByText('Unavailable')).toBeInTheDocument()
expect(
within(screen.getByRole('option', { name: /North Virginia/ })).queryByText('Unavailable')
).not.toBeInTheDocument()
await user.click(saoPaulo)
expect(await screen.findByText(copy.title)).toBeInTheDocument()
expect(screen.getByText(copy.notice, { exact: false })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Create new project' }))
const message = `${copy.title}. Select a different region to continue.`
expect(await screen.findByText(message)).toBeInTheDocument()
expect(onRequest).not.toHaveBeenCalled()
await selectRegion(/Americas/)
await waitFor(() => expect(screen.queryByText(message)).not.toBeInTheDocument())
expect(screen.queryByText(copy.title)).not.toBeInTheDocument()
}
test('keeps a capacity-restricted region selectable, explains it, and blocks submission', async () => {
mockWizardEndpoints({ availableRegions: availableRegionsWithSaoPaulo('capacity') })
const onRequest = vi.fn()
mockCreateProject(onRequest)
await renderWizard()
await expectSelectableRestrictedRegion(onRequest, CAPACITY_COPY)
})
test('labels an "other" platform status with generic copy instead of greying it out silently', async () => {
mockWizardEndpoints({ availableRegions: availableRegionsWithSaoPaulo('other') })
const onRequest = vi.fn()
mockCreateProject(onRequest)
await renderWizard()
await expectSelectableRestrictedRegion(onRequest, GENERIC_COPY)
})
test('applies a flag-only restriction the same way when the platform reports no status', async () => {
mockWizardEndpoints({ availableRegions: availableRegionsWithSaoPaulo() })
const onRequest = vi.fn()
mockCreateProject(onRequest)
await renderWizard({
flags: { projectCreationRestrictedRegions: '{"sa-east-1":"unavailable"}' },
})
await expectSelectableRestrictedRegion(onRequest, GENERIC_COPY)
})
})
})
describe('compute size and spend confirmation', () => {