Files
supabase/apps/studio/components/layouts/Tabs/Tabs.utils.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

116 lines
4.4 KiB
TypeScript

import { act, renderHook } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it } from 'vitest'
import { useSqlEditorTabsCleanup } from './Tabs.utils'
import { sqlEditorState } from '@/state/sql-editor/sql-editor-state'
import { createTabsState, TabsStateContext, type Tab } from '@/state/tabs'
import { seedSnippet } from '@/tests/lib/sql-editor-test-utils'
const dbTab = (id: string): Tab => ({
id: `sql-${id}`,
type: 'sql',
label: id,
isPreview: false,
metadata: { sqlId: id, sqlSource: 'database' },
})
const logsTab = (id: string): Tab => ({
id: `sql-${id}`,
type: 'sql',
label: id,
isPreview: false,
metadata: { sqlId: id, sqlSource: 'logs' },
})
function renderCleanup(store: ReturnType<typeof createTabsState>) {
const wrapper = ({ children }: { children: ReactNode }) => (
<TabsStateContext.Provider value={store}>{children}</TabsStateContext.Provider>
)
return renderHook(() => useSqlEditorTabsCleanup(), { wrapper }).result.current
}
describe('useSqlEditorTabsCleanup', () => {
afterEach(() => {
for (const key of Object.keys(sqlEditorState.snippets)) delete sqlEditorState.snippets[key]
})
it('keeps the tab of a locally-created snippet that has not been persisted yet', () => {
const store = createTabsState('default')
store.addTab(dbTab('existing'))
store.addTab(dbTab('brand-new'))
// A snippet created by typing in a new tab: it lives in the local store with
// a never-persisted status, but the server-fetched snippet list can't know
// about it yet.
seedSnippet({ id: 'brand-new', name: 'Untitled query', sql: 'select 1;' })
const cleanup = renderCleanup(store)
act(() => cleanup({ snippets: [{ id: 'existing', type: 'sql', name: 'existing' }] }))
expect(store.tabsMap['sql-brand-new']).toBeDefined()
expect(store.openTabs).toContain('sql-brand-new')
expect(store.activeTab).toEqual('sql-brand-new')
})
it('prunes tabs for deleted snippets (database and logs) while keeping live ones', () => {
const store = createTabsState('default')
store.addTab(dbTab('db-stale'))
store.addTab(logsTab('logs-stale'))
store.addTab(logsTab('logs-live'))
const cleanup = renderCleanup(store)
// The caller passes both `sql` and `log_sql` snippets it knows to be live; only
// `logs-live` is present, so the two stale tabs are pruned and it is kept.
act(() => cleanup({ snippets: [{ id: 'logs-live', type: 'log_sql', name: 'logs-live' }] }))
expect(store.openTabs).toEqual(['sql-logs-live'])
expect(store.tabsMap['sql-db-stale']).toBeUndefined()
expect(store.tabsMap['sql-logs-stale']).toBeUndefined()
expect(store.tabsMap['sql-logs-live']).toBeDefined()
})
it('keeps a logs tab that still exists in the snippet list', () => {
const store = createTabsState('default')
store.addTab(logsTab('logs'))
const cleanup = renderCleanup(store)
act(() => cleanup({ snippets: [{ id: 'logs', type: 'log_sql', name: 'logs' }] }))
expect(store.openTabs).toEqual(['sql-logs'])
})
it('preserves logs tabs when logs snippets are not authoritative (canPruneLogsTabs false)', () => {
const store = createTabsState('default')
store.addTab(dbTab('db-stale'))
store.addTab(logsTab('logs'))
const cleanup = renderCleanup(store)
// Logs section disabled / query errored: no logs snippets passed, and logs tabs
// must not be pruned — but stale database tabs still are.
act(() => cleanup({ snippets: [], canPruneLogsTabs: false }))
expect(store.openTabs).toEqual(['sql-logs'])
expect(store.tabsMap['sql-db-stale']).toBeUndefined()
// Recent items carry their own logs-source condition, so assert them separately.
const recentIds = store.recentItems.map((item) => item.id)
expect(recentIds).toContain('sql-logs')
expect(recentIds).not.toContain('sql-db-stale')
})
it('prunes recent items for deleted logs snippets while keeping live ones', () => {
const store = createTabsState('default')
store.addTab(logsTab('logs-stale'))
store.addTab(logsTab('logs-live'))
const cleanup = renderCleanup(store)
act(() => cleanup({ snippets: [{ id: 'logs-live', type: 'log_sql', name: 'logs-live' }] }))
const recentIds = store.recentItems.map((item) => item.id)
expect(recentIds).toContain('sql-logs-live')
expect(recentIds).not.toContain('sql-logs-stale')
})
})