diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.test.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.test.tsx
new file mode 100644
index 00000000000..1113bd5bf07
--- /dev/null
+++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.test.tsx
@@ -0,0 +1,138 @@
+import { untrustedSql } from '@supabase/pg-meta'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { mockAnimationsApi } from 'jsdom-testing-mocks'
+import { http, HttpResponse } from 'msw'
+import { describe, expect, test, vi } from 'vitest'
+
+import { RenameQueryModal } from './RenameQueryModal'
+import type { SnippetWithContent } from '@/data/content/sql-folders-query'
+import { customRender } from '@/tests/lib/custom-render'
+import { addAPIMock, mswServer } from '@/tests/lib/msw'
+
+mockAnimationsApi()
+
+const createSnippet = (id: string, name: string): SnippetWithContent => ({
+ id,
+ name,
+ description: undefined,
+ status: 'saved',
+ visibility: 'user',
+ owner_id: 1,
+ project_id: 1,
+ favorite: false,
+ inserted_at: '2026-08-07T14:19:25.711Z',
+ updated_at: '2026-08-07T14:19:25.711Z',
+ folder_id: 'private-folder',
+ type: 'sql',
+ content: {
+ content_id: id,
+ schema_version: '1',
+ unchecked_sql: untrustedSql('select 1;'),
+ },
+})
+
+const SNIPPET_A = createSnippet('snippet-a', 'First query')
+const SNIPPET_B = createSnippet('snippet-b', 'Second query')
+
+/** The modal renders the AI title generator, which checks for an OpenAI key when self-hosted. */
+const mockOpenAIKeyCheck = () =>
+ mswServer.use(
+ http.get('*/api/ai/sql/check-api-key', () => HttpResponse.json({ hasKey: false })),
+ http.get('*/platform/projects/default', () => HttpResponse.json({}, { status: 404 }))
+ )
+
+const mockUpsert = () => {
+ const requests: Array<{ name: string; id: string }> = []
+ addAPIMock({
+ method: 'put',
+ path: '/platform/projects/:ref/content',
+ response: async ({ request }) => {
+ const body = (await request.json()) as { id: string; name: string }
+ requests.push({ id: body.id, name: body.name })
+ return HttpResponse.json({ ...SNIPPET_A, ...body })
+ },
+ })
+ return requests
+}
+
+const getNameInput = () => screen.getByLabelText('Name')
+
+describe('RenameQueryModal', () => {
+ test('submits the new name for the selected snippet', async () => {
+ mockOpenAIKeyCheck()
+ const requests = mockUpsert()
+ const onComplete = vi.fn()
+
+ customRender(
+
+ )
+
+ await userEvent.clear(getNameInput())
+ await userEvent.type(getNameInput(), 'Renamed query')
+ fireEvent.click(screen.getByRole('button', { name: 'Rename query' }))
+
+ await waitFor(() => expect(onComplete).toHaveBeenCalledOnce())
+ expect(requests).toEqual([{ id: 'snippet-a', name: 'Renamed query' }])
+ })
+
+ test('shows the next snippet name after a successful rename (FE-4114)', async () => {
+ mockOpenAIKeyCheck()
+ mockUpsert()
+ const onComplete = vi.fn()
+
+ const { rerender } = customRender(
+
+ )
+
+ await userEvent.clear(getNameInput())
+ await userEvent.type(getNameInput(), 'Renamed query')
+ fireEvent.click(screen.getByRole('button', { name: 'Rename query' }))
+ await waitFor(() => expect(onComplete).toHaveBeenCalledOnce())
+
+ // The parent closes the modal, then reopens it for a different snippet
+ rerender(
+
+ )
+ rerender(
+
+ )
+
+ await waitFor(() => expect(getNameInput()).toHaveValue('Second query'))
+ // Nothing has changed yet, so there is nothing to submit
+ expect(screen.getByRole('button', { name: 'Rename query' })).toBeDisabled()
+ })
+
+ test('discards an abandoned edit when cancelled', async () => {
+ mockOpenAIKeyCheck()
+ const onCancel = vi.fn()
+
+ const { rerender } = customRender(
+
+ )
+
+ await userEvent.clear(getNameInput())
+ await userEvent.type(getNameInput(), 'Half-typed name')
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ expect(onCancel).toHaveBeenCalledOnce()
+
+ rerender(
+
+ )
+ rerender(
+
+ )
+
+ await waitFor(() => expect(getNameInput()).toHaveValue('First query'))
+ })
+})
diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx
index 525848f81cf..1ae91dc20c1 100644
--- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx
@@ -1,7 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useParams } from 'common'
import { useRouter } from 'next/router'
-import { useEffect } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
@@ -146,6 +145,7 @@ export const RenameQueryModal = ({
}
toast.success('Successfully renamed snippet!')
+ reset({ name, description }, { keepDirtyValues: false })
if (onComplete) onComplete()
} catch (error: any) {
// [Joshen] We probably need some rollback cause all the saving is async
@@ -156,18 +156,15 @@ export const RenameQueryModal = ({
const form = useForm>({
resolver: zodResolver(formSchema),
defaultValues: { name: name ?? '', description: description ?? '' },
+ values: { name: name ?? '', description: description ?? '' },
+ resetOptions: { keepDirtyValues: true },
})
const { reset, formState } = form
const { isDirty, isSubmitting } = formState
- useEffect(() => {
- if (isDirty) return
- reset({ name: name ?? '', description: description ?? '' })
- }, [id, name, description, reset, isDirty])
-
const handleCancel = () => {
onCancel()
- reset()
+ reset(undefined, { keepDirtyValues: false })
}
return (