feat(sql-editor): roll out manual saving by default (#48706)

## 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?

Feature — progresses the SQL Editor manual saving rollout, and removes a
fully rolled out feature flag.

## What is the current behavior?

Manual saving (snippets save only on an explicit Save / Cmd+S rather
than autosaving every edit) is opt-in. It requires both the
`sqlEditorManualSave` ConfigCat flag and the user turning on the
"Disable snippet auto-saving" feature preview themselves. That flag is
now fully rolled out.

## What is the new behavior?

- `sqlEditorManualSave` is removed, so the feature preview is listed for
everyone. (Delete the flag in ConfigCat after a few months.)
- New `sqlEditorManualSaveForced` flag progresses the rollout. It forces
manual saving on regardless of what the user stored previously,
including an explicit opt-out, via a new `isForced` field on
`FeaturePreview` that `initializeFlags` resolves ahead of the
localStorage lookup — so the feature preview modal reflects the forced
state too, not just the save behavior. Turning the flag off reverts
everyone who never opted in, so it remains a working kill switch.
- Users the rollout switches over get a one-time dialog on their first
SQL Editor route, explaining that snippets no longer autosave. Dismissal
persists in `sql-editor-manual-save-notice-dismissed` (allowlisted, so
it survives sign-out).
- Users who opted into the preview themselves never see the dialog — it
records their dismissal up front instead, since the notice needs to
outlive the feature preview and once the preview is retired there's no
stored opt-in left to recognize them by.
- The preview keeps its switch so users who lose their local storage can
opt in early, but once the rollout reaches them the "Disable feature"
button is disabled with a tooltip explaining why.

### To test

Turn on `sqlEditorManualSaveForced` on via the dev toolbar.

- No `supabase-ui-sql-editor-manual-save` and no dismissal key → dialog
appears on `/project/<ref>/sql`, toolbar shows the Save button. Dismiss,
reload → no dialog.
- `supabase-ui-sql-editor-manual-save` set to `false` (previously opted
out) → still forced onto manual saving, and still gets the dialog.
- `supabase-ui-sql-editor-manual-save` set to `true` → no dialog, and
the dismissal key is written automatically.
- Flag off, no opt-in → autosave, no dialog, and the "Disable autosave"
power-off button still opens the preview modal.
- Preview modal while forced → "Disable feature" is disabled with a
tooltip; another preview (e.g. Column-level privileges) still disables
normally.

## Additional context
This commit is contained in:
Charis authored and GitHub committed 2026-08-04 12:16:18 -04:00
1 parent e7d9c88cbc
commit 0791b04eb8
10 files changed
+221 -17

No files matched your search

@@ -49,6 +49,14 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren) =
return { ...a, [b.key]: false }
}
// A forced preview has become the default behavior, so it's on whatever
// the user stored previously — including an explicit opt-out. Applied
// here rather than in the individual `useIsXEnabled` helpers so that the
// feature preview modal reflects it too.
if (b.isForced) {
return { ...a, [b.key]: true }
}
const defaultOptIn = b.isDefaultOptIn
const localStorageValue = safeLocalStorage.getItem(b.key)
return {
@@ -127,10 +135,14 @@ export const useIsJitDbAccessEnabled = () => {
return jitDbAccessEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS]
}
/**
* Whether the SQL Editor saves snippets only on request. True when the user
* opted into the preview themselves *or* the `sqlEditorManualSaveForced` rollout
* has reached them — the preview's `isForced` flag folds the latter into `flags`.
*/
export const useIsSqlEditorManualSaveEnabled = () => {
const { flags } = useFeaturePreviewContext()
const sqlEditorManualSaveEnabled = useFlag('sqlEditorManualSave')
return sqlEditorManualSaveEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]
}
export const useIsMarketplaceEnabled = () => {
@@ -43,6 +43,7 @@ import { SqlEditorManualSavePreview } from './SqlEditorManualSavePreview'
import { UnifiedLogsPreview } from './UnifiedLogsPreview'
import { FeaturePreview, useFeaturePreviews } from './useFeaturePreviews'
import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { IS_PLATFORM } from '@/lib/constants'
import { useTrack } from '@/lib/telemetry/track'
@@ -83,6 +84,7 @@ export const FeaturePreviewModal = () => {
allFeaturePreviews.find((preview) => preview.key === selectedFeatureKey) ??
allFeaturePreviews[0]
const isSelectedFeatureEnabled = flags[selectedFeature?.key]
const canDisableSelectedFeature = selectedFeature?.isForced !== true
const selectedFeatureRoute = selectedFeature?.getRoute?.(ref)
const hasRoute = selectedFeatureRoute !== undefined && ref !== undefined
@@ -229,11 +231,25 @@ export const FeaturePreviewModal = () => {
</Link>
</Button>
)}
{isSelectedFeatureEnabled ? (
<Button variant="default" onClick={() => toggleFeature()}>
{isSelectedFeatureEnabled && (
<ButtonTooltip
variant="default"
disabled={!canDisableSelectedFeature}
onClick={() => toggleFeature()}
tooltip={{
content: {
side: 'bottom',
className: 'max-w-64 text-center',
text: canDisableSelectedFeature
? undefined
: 'This feature is now the default and can no longer be turned off',
},
}}
>
Disable feature
</Button>
) : (
</ButtonTooltip>
)}
{!isSelectedFeatureEnabled && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="default" onClick={() => toggleFeature()}>
@@ -12,6 +12,11 @@ export const SqlEditorManualSavePreview = () => {
<li>Let you save with Cmd+S at any time</li>
</ul>
</div>
<p className="text-sm text-foreground-light">
Manual saving is becoming the default for the SQL Editor. This preview can no longer be
turned off. To opt into manual saving before the change reaches your account, enable this
preview.
</p>
</div>
)
}
@@ -14,6 +14,12 @@ export type FeaturePreview = {
isDefaultOptIn: boolean
/** Visibility in the feature preview modal (For feature flagging a feature preview) */
enabled: boolean
/**
* Forces the preview on for this user, whatever they previously chose — for a
* preview that has become the default behavior. Overrides both `isDefaultOptIn`
* and a stored opt-out, and takes away the ability to turn the preview back off.
*/
isForced?: boolean
/** Optional category that the feature preview falls under, defaults to "Others" in the UI otherwise */
category?: 'observability' | 'database'
/**
@@ -28,10 +34,10 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
const isPlatformWebhooksEnabled = useFlag('platformWebhooks')
const jitDbAccessEnabled = useFlag('jitDbAccess')
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
const isSqlEditorManualSaveEnabled = useFlag('sqlEditorManualSave')
const isDatabaseConnectionsEnabled = useFlag('topForPostgres')
const unifiedLogsDefaultOptIn = useFlag('unifiedLogsDefaultOptIn')
const isSqlEditorManualSaveForced = useFlag('sqlEditorManualSaveForced')
return useMemo(() => {
const previews: FeaturePreview[] = [
@@ -114,7 +120,11 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
isNew: true,
isPlatformOnly: true,
isDefaultOptIn: false,
enabled: isSqlEditorManualSaveEnabled,
enabled: true,
// Manual saving is becoming the default for the SQL Editor. The preview
// stays listed so users who lose their local storage can opt back in
// before the rollout reaches them.
isForced: isSqlEditorManualSaveForced,
},
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_DATABASE_CONNECTIONS,
@@ -133,10 +143,10 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
return previews.sort((a, b) => Number(b.isNew) - Number(a.isNew))
}, [
unifiedLogsDefaultOptIn,
isSqlEditorManualSaveForced,
isPlatformWebhooksEnabled,
jitDbAccessEnabled,
isMarketplaceEnabled,
isSqlEditorManualSaveEnabled,
isDatabaseConnectionsEnabled,
])
}
@@ -0,0 +1,85 @@
import { LOCAL_STORAGE_KEYS, safeLocalStorage, useFlag } from 'common'
import { useEffect } from 'react'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
KeyboardShortcut,
} from 'ui'
import { shouldShowManualSaveNotice } from './SqlEditorManualSaveNoticeDialog.utils'
import { useFeaturePreviewContext } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { IS_PLATFORM } from '@/lib/constants'
/**
* One-time notice for users the manual saving rollout switches over, shown the
* first time they land on a SQL Editor route. Users who opted into the preview
* themselves already know the behavior and never see it.
*/
export const SqlEditorManualSaveNoticeDialog = () => {
const { isInitialized } = useFeaturePreviewContext()
const isForced = useFlag('sqlEditorManualSaveForced')
const [isNoticeDismissed, setIsNoticeDismissed, { isSuccess: isDismissalLoaded }] =
useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_MANUAL_SAVE_NOTICE_DISMISSED, false)
const hasOptedIntoPreview =
safeLocalStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE) === 'true'
// Record the dismissal up front for users who opted in themselves. The notice
// outlives the feature preview — once the preview is retired there's no stored
// opt-in left to recognize them by, and without this they'd be shown a notice
// about behavior they chose.
useEffect(() => {
if (isDismissalLoaded && hasOptedIntoPreview && !isNoticeDismissed) {
setIsNoticeDismissed(true)
}
}, [isDismissalLoaded, hasOptedIntoPreview, isNoticeDismissed, setIsNoticeDismissed])
const isOpen =
isDismissalLoaded &&
shouldShowManualSaveNotice({
isPlatform: IS_PLATFORM,
isFeaturePreviewInitialized: isInitialized,
isForced,
hasOptedIntoPreview,
isNoticeDismissed,
})
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && setIsNoticeDismissed(true)}>
<DialogContent size="medium">
<DialogHeader>
<DialogTitle>Snippets no longer save automatically</DialogTitle>
<DialogDescription>
The SQL Editor now saves your snippets only when you save them.
</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="text-sm">
<ul className="list-disc pl-6 text-foreground-light space-y-1">
<li>
Save with the Save button next to Run, or with{' '}
<KeyboardShortcut keys={['Meta', 's']} />
</li>
<li>Tabs with unsaved edits show a dot</li>
<li>Closing a tab with unsaved edits discards them</li>
</ul>
</DialogSection>
<DialogFooter>
<Button onClick={() => setIsNoticeDismissed(true)}>Understood</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import {
shouldShowManualSaveNotice,
type ShouldShowManualSaveNoticeParams,
} from './SqlEditorManualSaveNoticeDialog.utils'
const eligible: ShouldShowManualSaveNoticeParams = {
isPlatform: true,
isFeaturePreviewInitialized: true,
isForced: true,
hasOptedIntoPreview: false,
isNoticeDismissed: false,
}
describe('shouldShowManualSaveNotice', () => {
it('shows the notice to a user the rollout switches over', () => {
expect(shouldShowManualSaveNotice(eligible)).toBe(true)
})
it('does not show the notice to a user who opted into the preview themselves', () => {
expect(shouldShowManualSaveNotice({ ...eligible, hasOptedIntoPreview: true })).toBe(false)
})
it('does not show the notice once dismissed', () => {
expect(shouldShowManualSaveNotice({ ...eligible, isNoticeDismissed: true })).toBe(false)
})
it('does not show the notice when the rollout flag is off', () => {
expect(shouldShowManualSaveNotice({ ...eligible, isForced: false })).toBe(false)
})
it('does not show the notice outside of the hosted platform', () => {
expect(shouldShowManualSaveNotice({ ...eligible, isPlatform: false })).toBe(false)
})
it('does not show the notice before the feature previews have initialized', () => {
expect(shouldShowManualSaveNotice({ ...eligible, isFeaturePreviewInitialized: false })).toBe(
false
)
})
})
@@ -0,0 +1,36 @@
export interface ShouldShowManualSaveNoticeParams {
/** Manual saving is a platform-only feature preview */
isPlatform: boolean
/** Whether the feature preview flags reflect loaded values rather than pre-load defaults */
isFeaturePreviewInitialized: boolean
/** Whether the rollout flag forces manual saving on for this user */
isForced: boolean
/** Whether the user explicitly enabled the manual save preview themselves */
hasOptedIntoPreview: boolean
isNoticeDismissed: boolean
}
/**
* Whether to show the one-time notice explaining that snippets no longer
* autosave.
*
* Everyone the rollout switches over needs telling — including users who
* explicitly opted *out* of the preview, since the rollout overrides that
* choice. Only users who turned manual saving on themselves already know what
* the behavior is.
*/
export function shouldShowManualSaveNotice({
isPlatform,
isFeaturePreviewInitialized,
isForced,
hasOptedIntoPreview,
isNoticeDismissed,
}: ShouldShowManualSaveNoticeParams): boolean {
return (
isPlatform &&
isFeaturePreviewInitialized &&
isForced &&
!hasOptedIntoPreview &&
!isNoticeDismissed
)
}
@@ -1,4 +1,4 @@
import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
import { LOCAL_STORAGE_KEYS } from 'common'
import { PowerOff } from 'lucide-react'
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
@@ -19,13 +19,7 @@ export const AutosaveStatus = ({ id }: AutosaveStatusProps) => {
const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled()
const { selectFeaturePreview } = useFeaturePreviewModal()
// Whether the manual-save preview can actually be opted into. Mirrors the
// feature preview modal's own filter (platform-only + ConfigCat flag), so we
// don't offer to "disable autosave" when there's no preview to switch to.
// `isManualSaveEnabled` also being false for self-hosted / flag-off users is
// why it can't gate this affordance.
const sqlEditorManualSaveFlag = useFlag('sqlEditorManualSave')
const canEnableManualSave = IS_PLATFORM && sqlEditorManualSaveFlag
const canEnableManualSave = IS_PLATFORM
if (isManualSaveEnabled) {
const snippet = snapV2.snippets[id]
@@ -1,6 +1,7 @@
import { ReactNode } from 'react'
import { OngoingQueriesPanel } from '@/components/interfaces/SQLEditor/OngoingQueriesPanel'
import { SqlEditorManualSaveNoticeDialog } from '@/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog'
import { withAuth } from '@/hooks/misc/withAuth'
export interface SQLEditorLayoutProps {
@@ -12,6 +13,7 @@ const SQLEditorLayout = ({ children }: SQLEditorLayoutProps) => {
<>
{children}
<OngoingQueriesPanel />
<SqlEditorManualSaveNoticeDialog />
</>
)
}
@@ -50,6 +50,7 @@ export const LOCAL_STORAGE_KEYS = {
SQL_EDITOR_SQL_BLOCK_ACKNOWLEDGED: (ref: string) => `sql-editor-sql-block-acknowledged-${ref}`,
SQL_EDITOR_SECTION_STATE: (ref: string) => `sql-editor-section-state-${ref}`,
SQL_EDITOR_SORT: (ref: string) => `sql-editor-sort-${ref}`,
SQL_EDITOR_MANUAL_SAVE_NOTICE_DISMISSED: 'sql-editor-manual-save-notice-dismissed',
LOG_EXPLORER_SPLIT_SIZE: 'supabase_log-explorer-split-size',
GRAPHQL_INTROSPECTION_NOTICE_COLLAPSED: (ref: string) =>
@@ -158,6 +159,7 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [
LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS,
LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS,
LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE,
LOCAL_STORAGE_KEYS.SQL_EDITOR_MANUAL_SAVE_NOTICE_DISMISSED,
LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE,
LOCAL_STORAGE_KEYS.LAST_SIGN_IN_METHOD,
LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST,