mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 03:45:06 +03:00
## 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 -->
116 lines
4.4 KiB
TypeScript
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')
|
|
})
|
|
})
|