mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55: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 -->
194 lines
7.4 KiB
TypeScript
194 lines
7.4 KiB
TypeScript
import { useCallback, useEffect, useRef } from 'react'
|
|
|
|
import { Entity } from '@/data/entity-types/entity-types-infinite-query'
|
|
import { useLatest } from '@/hooks/misc/useLatest'
|
|
import { wasNeverPersisted } from '@/state/sql-editor/sql-editor-lifecycle'
|
|
import { getSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
|
|
import { createTabId, editorEntityTypes, useTabsStateSnapshot } from '@/state/tabs'
|
|
|
|
export function useTableEditorTabsCleanUp() {
|
|
const tabs = useTabsStateSnapshot()
|
|
const tabMapRef = useLatest(tabs.tabsMap)
|
|
const recentItemsRef = useLatest(tabs.recentItems)
|
|
const openTabsRef = useLatest(tabs.openTabs)
|
|
|
|
return useCallback(({ schemas, entities }: { schemas: string[]; entities: Entity[] }) => {
|
|
// Identity all entities by tab ID
|
|
const entitiesById: string[] = entities.map((x: Entity) => createTabId(x.type, { id: x.id }))
|
|
|
|
const recentItemsFilteredToSchemas: string[] = []
|
|
for (const schema of schemas) {
|
|
recentItemsFilteredToSchemas.push(
|
|
...recentItemsRef.current.filter((x) => x.metadata?.schema === schema).map((x) => x.id)
|
|
)
|
|
}
|
|
|
|
const recentItemsToRemove = [
|
|
...recentItemsFilteredToSchemas.filter((entityId) => !entitiesById.includes(entityId)),
|
|
]
|
|
|
|
tabs.removeRecentItems(recentItemsToRemove)
|
|
|
|
const tabsFilteredToSchemas: string[] = []
|
|
for (const schema of schemas) {
|
|
tabsFilteredToSchemas.push(
|
|
...openTabsRef.current.filter((tabId) => {
|
|
const tab = tabMapRef.current[tabId]
|
|
return tab?.metadata?.schema === schema
|
|
})
|
|
)
|
|
}
|
|
|
|
const tableEditorTabsToBeCleaned = [
|
|
...tabsFilteredToSchemas.filter(
|
|
(id) => !id.startsWith('sql') && !id.startsWith('schema') && !entitiesById.includes(id)
|
|
),
|
|
]
|
|
|
|
// perform tabs cleanup
|
|
tabs.removeTabs(tableEditorTabsToBeCleaned)
|
|
|
|
// [Joshen] Validate for opened tabs, if their label matches the entity's name - update label if not
|
|
// As the entity could've been renamed outside of the table editor
|
|
// e.g Using the SQL editor to rename the entity
|
|
const openTabs = openTabsRef.current
|
|
.map((id) => tabMapRef.current[id])
|
|
.filter((tab) => !!tab && editorEntityTypes['table']?.includes(tab.type))
|
|
|
|
openTabs.forEach((tab) => {
|
|
const entity = entities?.find((x) => tab.metadata?.tableId === x.id)
|
|
if (!!entity && entity.name !== tab.label) tabs.updateTab(tab.id, { label: entity.name })
|
|
})
|
|
}, [])
|
|
}
|
|
|
|
export function useSqlEditorTabsCleanup() {
|
|
const tabs = useTabsStateSnapshot()
|
|
const tabMapRef = useLatest(tabs.tabsMap)
|
|
const openTabsRef = useLatest(tabs.openTabs)
|
|
|
|
return useCallback(
|
|
({
|
|
snippets,
|
|
canPruneLogsTabs = true,
|
|
}: {
|
|
snippets: { id: string; type: string; name: string }[]
|
|
// Whether `log_sql` snippets in `snippets` are authoritative. When false (the
|
|
// logs section is disabled or its query errored) we can't know which logs
|
|
// snippets exist, so logs tabs are preserved rather than pruned as stale.
|
|
canPruneLogsTabs?: boolean
|
|
}) => {
|
|
// these are tabs that are static content
|
|
// these canot be removed from localstorage based on this query request
|
|
const IGNORED_TAB_IDS = ['sql-templates', 'sql-quickstarts']
|
|
|
|
// Identify all SQL snippets / content by their tab ids. Both database (`sql`) and
|
|
// logs (`log_sql`) snippets live in the `sql-` tab id space, so both are counted as
|
|
// live. Anything not in this set is treated as removed outside the session and pruned.
|
|
const currentContentIds = [
|
|
...snippets
|
|
.filter((content) => content.type === 'sql' || content.type === 'log_sql')
|
|
.map((content) => createTabId('sql', { id: content.id })),
|
|
// append ignored tab IDs
|
|
...IGNORED_TAB_IDS,
|
|
]
|
|
|
|
// A snippet created during this session (by typing in a new tab) exists only
|
|
// in the local store until its first save lands, so the server-fetched list
|
|
// legitimately doesn't contain it yet. Without this, invalidating the snippet
|
|
// lists on that first keystroke prunes the tab that was just opened — the URL
|
|
// and editor stay on the new snippet while the tab bar falls back to whichever
|
|
// tab was open before, making it look like the previous tab is being edited.
|
|
const localSnippets = getSqlEditorV2StateSnapshot().snippets
|
|
const isUnpersistedLocalSnippet = (sqlId: string | undefined) =>
|
|
sqlId !== undefined && wasNeverPersisted(localSnippets[sqlId]?.snippet.status)
|
|
|
|
const isPrunable = (id: string) =>
|
|
id.startsWith('sql') &&
|
|
!currentContentIds.includes(id) &&
|
|
!isUnpersistedLocalSnippet(tabMapRef.current[id]?.metadata?.sqlId) &&
|
|
(canPruneLogsTabs || tabMapRef.current[id]?.metadata?.sqlSource !== 'logs')
|
|
|
|
// Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session)
|
|
const snippetTabsToBeCleaned = openTabsRef.current.filter(isPrunable)
|
|
tabs.removeTabs(snippetTabsToBeCleaned)
|
|
|
|
// Remove any recent items that might no longer be existing (removed outside of the dashboard session)
|
|
const recentItems = tabs.getRecentItemsByType('sql')
|
|
tabs.removeRecentItems(
|
|
recentItems
|
|
? recentItems
|
|
.filter(
|
|
(item) =>
|
|
!currentContentIds.includes(item.id) &&
|
|
!isUnpersistedLocalSnippet(item.metadata?.sqlId) &&
|
|
(canPruneLogsTabs || item.metadata?.sqlSource !== 'logs')
|
|
)
|
|
.map((item) => item.id)
|
|
: []
|
|
)
|
|
|
|
// [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not
|
|
// As the snippets name could've been updated outside of the SQL Editor session
|
|
// e.g for a shared snippet, the owner could've updated the name of the snippet
|
|
const openSqlTabs = openTabsRef.current
|
|
.map((id) => tabMapRef.current[id])
|
|
.filter((tab) => !!tab && editorEntityTypes['sql']?.includes(tab.type))
|
|
|
|
openSqlTabs.forEach((tab) => {
|
|
const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id)
|
|
if (!!snippet && snippet.name !== tab.label) tabs.updateTab(tab.id, { label: snippet.name })
|
|
})
|
|
},
|
|
[]
|
|
)
|
|
}
|
|
|
|
interface UseTabsScrollOptions {
|
|
activeTab: string | null | undefined
|
|
tabCount: number
|
|
}
|
|
|
|
export function useTabsScroll({ activeTab, tabCount }: UseTabsScrollOptions) {
|
|
const tabsListRef = useRef<HTMLDivElement>(null)
|
|
const prevTabCountRef = useRef<number>(tabCount)
|
|
const isInitialMount = useRef(true)
|
|
|
|
useEffect(() => {
|
|
if (tabsListRef.current) {
|
|
tabsListRef.current.scrollLeft = tabsListRef.current.scrollWidth
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (isInitialMount.current) {
|
|
isInitialMount.current = false
|
|
return
|
|
}
|
|
|
|
if (!tabsListRef.current) return
|
|
|
|
const tabCountIncreased = tabCount > prevTabCountRef.current
|
|
|
|
if (tabCountIncreased) {
|
|
tabsListRef.current.scrollLeft = tabsListRef.current.scrollWidth
|
|
} else if (activeTab) {
|
|
const activeTabElement = tabsListRef.current.querySelector(
|
|
`[data-state="active"]`
|
|
) as HTMLElement
|
|
|
|
if (activeTabElement) {
|
|
activeTabElement.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'nearest',
|
|
inline: 'nearest',
|
|
})
|
|
}
|
|
}
|
|
|
|
prevTabCountRef.current = tabCount
|
|
}, [activeTab, tabCount])
|
|
|
|
return { tabsListRef }
|
|
}
|