From 0791b04eb8ad0ba3998f42376675e91ff4f7cd6a Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:16:18 -0400 Subject: [PATCH] feat(sql-editor): roll out manual saving by default (#48706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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//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 --- .../FeaturePreview/FeaturePreviewContext.tsx | 16 +++- .../FeaturePreview/FeaturePreviewModal.tsx | 24 +++++- .../SqlEditorManualSavePreview.tsx | 5 ++ .../App/FeaturePreview/useFeaturePreviews.ts | 16 +++- .../SqlEditorManualSaveNoticeDialog.tsx | 85 +++++++++++++++++++ ...EditorManualSaveNoticeDialog.utils.test.ts | 42 +++++++++ .../SqlEditorManualSaveNoticeDialog.utils.ts | 36 ++++++++ .../SQLEditor/UtilityPanel/AutosaveStatus.tsx | 10 +-- .../SQLEditorLayout/SQLEditorLayout.tsx | 2 + packages/common/constants/local-storage.ts | 2 + 10 files changed, 221 insertions(+), 17 deletions(-) create mode 100644 apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.test.ts create mode 100644 apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.ts diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index a0aaea6f28e..557c0e1327e 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -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 = () => { diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index c6134343abb..24ad9662f92 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -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 = () => { )} - {isSelectedFeatureEnabled ? ( - - ) : ( + + )} + {!isSelectedFeatureEnabled && ( + + + + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.test.ts new file mode 100644 index 00000000000..3b68fef5667 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.test.ts @@ -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 + ) + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.ts b/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.ts new file mode 100644 index 00000000000..86556c08bba --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.ts @@ -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 + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx index abff6c14b7a..2391bdc4400 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx @@ -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] diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx index 6ce9d4ddad2..f97145a1a25 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx @@ -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} + ) } diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 39f7bf1bd8a..edf85c0a1f8 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -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,