Files
supabase/apps/studio/components/interfaces/SQLEditor/useSnippetEditor.test.tsx
Charis 4ca17a23ce fix(studio): keep the new SQL editor tab from being pruned on first keystroke (#48956)
## What kind of change does this PR introduce?

Bug fix.

## What is the current behavior?

With a snippet already open in the SQL Editor, clicking the `+` button
and typing in the new tab made it look like the previously open tab was
being taken over: the tab bar showed the *old* snippet's name while the
editor showed the newly typed content. With several tabs open, the
rightmost one appeared to be the one taken over.

`useSqlEditorTabsCleanup` prunes any `sql-*` tab whose snippet is absent
from the server-fetched snippet list, so tabs for snippets deleted in
another session don't linger. But a snippet created by typing in a new
tab exists only in the local store until its first save lands, so it is
legitimately absent from that list — and the same keystroke that creates
it calls `setSql({ shouldInvalidate: true })`, invalidating the snippet
lists and triggering a refetch that pruned the tab that had just been
opened.

`removeTab` then reassigns `activeTab` to a neighbor, so the tab bar
fell back to whichever tab was open before, while the URL, sidebar, and
editor content all stayed correctly on the new snippet — none of them
read from the tabs store.

Confirmed from a user's persisted tab state, which showed a single tab
in `openTabs` pointing at the previous snippet while the URL pointed at
the new one.

No snippet content was ever lost — this was tab state only.

## What is the new behavior?

Tabs (and recent items) whose snippet is present in the local store with
a never-persisted status (`new`, `new_saving`, `new_save_failed`) are
preserved by the cleanup pass. Genuine stale-tab pruning for snippets
removed outside the session is unaffected.

## Additional context

The regression test in `Tabs.utils.test.tsx` fails without the fix
(`expected undefined to be defined`) and passes with it. The
pre-existing pruning tests still pass, confirming legitimate cleanup
still works.

Also adds two tests covering adjacent invariants that were verified
while narrowing this down: snippet content isolation between tabs, and
unique snippet id generation across `/sql/new` navigations.

Verified: 343 tests pass across `components/layouts/Tabs` and
`components/interfaces/SQLEditor`; typecheck clean; ESLint warning count
unchanged (ratchet safe); Prettier clean.

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

## Summary by CodeRabbit

- **Bug Fixes**
- Preserved newly created SQL editor tabs and recent items before their
first save.
  - Prevented unsaved snippets from being removed during tab cleanup.
- Ensured editing a new tab does not overwrite content in existing tabs.
  - Ensured successive new SQL tabs receive distinct identities.

- **Tests**
- Added regression coverage for snippet editing, route changes, and tab
cleanup behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-12 09:30:59 -04:00

57 lines
2.0 KiB
TypeScript

import { act, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { useSnippetEditor } from './useSnippetEditor'
import { sqlEditorState } from '@/state/sql-editor/sql-editor-state'
import { createMockProfileContext } from '@/tests/lib/profile-helpers'
import {
renderSqlEditorHook,
resetSqlEditorStores,
seedSnippet,
setupSqlEditorMocks,
} from '@/tests/lib/sql-editor-test-utils'
const PROFILE_CONTEXT = createMockProfileContext()
describe('useSnippetEditor', () => {
beforeEach(() => {
resetSqlEditorStores()
setupSqlEditorMocks()
})
it('writing in a newly opened tab does not overwrite an already-open tab', async () => {
seedSnippet({ id: 'existing-tab', sql: 'select existing;' })
// Mount the editor for the already-open tab, mirroring the keyed
// MonacoEditor mount for that snippet id, then unmount it the way opening
// a new tab would (the `key={id}` wrapper swaps to a brand new instance).
const first = renderSqlEditorHook(useSnippetEditor, {
initialProps: { id: 'existing-tab', snippetName: 'Existing tab' },
profileContext: PROFILE_CONTEXT,
})
await waitFor(() => expect(first.result.current.snippet).toBeDefined())
first.unmount()
// Mount a fresh instance for a brand new tab, as clicking "+" would.
const second = renderSqlEditorHook(useSnippetEditor, {
initialProps: { id: 'new-tab-id', snippetName: 'New query' },
profileContext: PROFILE_CONTEXT,
})
// `handleEditorChange` only creates the new snippet once the project has
// loaded, so retry the keystroke until that happens.
await waitFor(() => {
act(() => {
second.result.current.handleEditorChange('select typed content;')
})
expect(sqlEditorState.snippets['new-tab-id']?.snippet.content?.unchecked_sql).toContain(
'typed content'
)
})
expect(sqlEditorState.snippets['existing-tab'].snippet.content?.unchecked_sql).toContain(
'select existing'
)
})
})