QueryTab: Scope row limit to each tab instead of global (#49138)

## Context

Previous PR [here](https://github.com/supabase/supabase/pull/49098)
introduced row limits to the Explorer -> Query Tab, but the setting was
global (e.g selected row limit value would be the same despite switching
query tabs)

Changes here shifts the scope of row limit into the query draft so that
the value is tied to each individual query tab instead

Also added a logic as CodeRabbit suggested
[here](https://github.com/supabase/supabase/pull/49138#discussion_r3795525802)
- to default invalid row limit values to 100 if the persisted data is
mutated incorrectly

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

## Summary by CodeRabbit

* **New Features**
* Query tabs now retain row-limit settings independently for each
database draft.
* Row-limit preferences are restored when reopening Studio, with older
drafts defaulting to 100 rows.

* **Bug Fixes**
* Changing the row limit now persists immediately and no longer affects
other query drafts.
  * Invalid saved row limits are safely normalized to a supported value.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-08-18 10:44:32 +08:00
1 parent fba3733148
commit cfde341c31
3 files changed
+126 -4

No files matched your search

@@ -19,7 +19,6 @@ export const QueryTab = () => {
const querySnap = useExplorerQueryStateSnapshot()
const roleImpersonationState = useLocalRoleImpersonationState()
const [rowLimit, setRowLimit] = useState<number>(100)
const [restoredQueryKey, setRestoredQueryKey] = useState<string>()
const stateDraft = id ? querySnap.drafts[id] : undefined
@@ -84,7 +83,7 @@ export const QueryTab = () => {
: {
...toQuerySourceBinding(draft),
uncheckedSql: draft.uncheckedSql,
rowLimit,
rowLimit: draft.rowLimit,
}
return (
@@ -103,7 +102,7 @@ export const QueryTab = () => {
onSqlChange={(sql) => explorerQueryState.updateDraft({ id, sql })}
onSourceChange={(source) => explorerQueryState.updateDraft({ id, source })}
onResultChange={handleResultChange}
onRowLimitChange={setRowLimit}
onRowLimitChange={(rowLimit) => explorerQueryState.updateDraft({ id, rowLimit })}
/>
)
}
+91
View File
@@ -202,6 +202,97 @@ describe('explorer query drafts', () => {
expect(persisted[`query-${MAX_PERSISTED_EXPLORER_QUERY_DRAFTS}`]).toBeDefined()
})
it('persists and restores a per-draft row limit independently of other drafts', () => {
const storage = createMemoryStorage()
const state = createExplorerQueryState(storage)
state.createDraft({ id: 'query-1', projectRef: 'project-a' })
state.createDraft({ id: 'query-2', projectRef: 'project-a' })
state.updateDraft({ id: 'query-1', rowLimit: 500 })
expect(state.drafts['query-1']).toMatchObject({ rowLimit: 500 })
expect(state.drafts['query-2']).toMatchObject({ rowLimit: 100 })
const restored = createExplorerQueryState(storage)
expect(restored.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
expect(restored.restoreDraft({ id: 'query-2', projectRef: 'project-a' })).toBe(true)
expect(restored.drafts['query-1']).toMatchObject({ rowLimit: 500 })
expect(restored.drafts['query-2']).toMatchObject({ rowLimit: 100 })
})
it('defaults the row limit for drafts persisted before row limits existed', () => {
const storage = createMemoryStorage()
storage.setItem(
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
JSON.stringify({
'query-1': { name: 'Legacy query', sql: 'select 1', updatedAt: 1 },
})
)
const state = createExplorerQueryState(storage)
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
expect(state.drafts['query-1']).toMatchObject({ rowLimit: 100 })
})
it('accepts every row limit the row limit menu can produce', () => {
const storage = createMemoryStorage()
for (const rowLimit of [-1, 100, 500, 1000]) {
storage.setItem(
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
JSON.stringify({
'query-1': { name: 'Query', sql: 'select 1', updatedAt: 1, rowLimit },
})
)
const state = createExplorerQueryState(storage)
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
expect(state.drafts['query-1']).toMatchObject({ rowLimit })
}
})
it('normalizes a fractional persisted row limit to the default', () => {
const storage = createMemoryStorage()
storage.setItem(
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
JSON.stringify({
'query-1': { name: 'Query', sql: 'select 1', updatedAt: 1, rowLimit: 100.5 },
})
)
const state = createExplorerQueryState(storage)
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
expect(state.drafts['query-1']).toMatchObject({ rowLimit: 100 })
})
it('normalizes an out-of-range persisted row limit to the default', () => {
const storage = createMemoryStorage()
storage.setItem(
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
JSON.stringify({
'query-1': { name: 'Query', sql: 'select 1', updatedAt: 1, rowLimit: 999999 },
})
)
const state = createExplorerQueryState(storage)
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
expect(state.drafts['query-1']).toMatchObject({ rowLimit: 100 })
})
it('normalizes a non-numeric persisted row limit to the default without dropping the draft', () => {
const storage = createMemoryStorage()
storage.setItem(
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
JSON.stringify({
'query-1': { name: 'Query', sql: 'select 1', updatedAt: 1, rowLimit: 'unlimited' },
})
)
const state = createExplorerQueryState(storage)
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
expect(state.drafts['query-1']).toMatchObject({ rowLimit: 100, uncheckedSql: 'select 1' })
})
it('removes the persisted draft and its session result when its tab closes', () => {
const storage = createMemoryStorage()
const state = createExplorerQueryState(storage)
+33 -1
View File
@@ -3,7 +3,9 @@ import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common'
import { proxy, ref, snapshot, useSnapshot } from 'valtio'
import { z } from 'zod'
import { DEFAULT_CELL_ROW_LIMIT } from '@/components/interfaces/Explorer/QueryCell/QueryCell.utils'
import { type QueryResult } from '@/components/interfaces/Explorer/types'
import { ROWS_PER_PAGE_OPTIONS } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
import {
type DatabaseSourceParameters,
type LogsSourceParameters,
@@ -33,6 +35,7 @@ export type DatabaseQueryDraft = ExplorerQueryDraftBase &
DatabaseSourceParameters & {
_tag: 'database'
uncheckedSql: UntrustedSqlFragment
rowLimit: number
}
export type LogsQueryDraft = ExplorerQueryDraftBase &
@@ -57,6 +60,7 @@ type PersistedExplorerQueryDraft = {
source: QuerySourceBinding
sql: string
updatedAt: number
rowLimit?: number
}
type PersistedExplorerQueryDrafts = Record<string, PersistedExplorerQueryDraft>
@@ -72,8 +76,23 @@ const persistedDraftSchema = z.object({
sql: z.string(),
updatedAt: z.number(),
source: z.unknown().optional(),
rowLimit: z.unknown().optional(),
})
const VALID_ROW_LIMITS = ROWS_PER_PAGE_OPTIONS.map((option) => option.value)
/**
* Falls back to the default whenever a persisted row limit isn't one of the values the row
* limit menu can actually produce — e.g. a fractional or out-of-range number from corrupted
* or hand-edited storage. `undefined` (never persisted) passes through unchanged; `toDraft`
* applies the default for that case.
*/
const rowLimitSchema = z
.number()
.refine((value) => VALID_ROW_LIMITS.includes(value))
.catch(DEFAULT_CELL_ROW_LIMIT)
.optional()
/**
* Rebuilds a draft from its persisted form, branding the SQL for the backend the binding
* names. The single place a stored string re-enters the type system as untrusted SQL, which
@@ -104,6 +123,7 @@ const toDraft = ({
_tag: 'database',
database_identifier: persisted.source.database_identifier,
uncheckedSql: untrustedSql(persisted.sql),
rowLimit: persisted.rowLimit ?? DEFAULT_CELL_ROW_LIMIT,
}
}
@@ -125,6 +145,8 @@ const readPersistedDrafts = (storage: StorageLike, projectRef: string) => {
? parsedSource.data
: createDefaultSourceBinding('database')
const rowLimit = rowLimitSchema.parse(draft.data.rowLimit)
return [
[
id,
@@ -133,6 +155,7 @@ const readPersistedDrafts = (storage: StorageLike, projectRef: string) => {
source,
sql: draft.data.sql,
updatedAt: draft.data.updatedAt,
rowLimit,
},
],
]
@@ -172,6 +195,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
source: toQuerySourceBinding(draft),
sql: draft.uncheckedSql,
updatedAt: draft.updatedAt,
rowLimit: draft._tag === 'database' ? draft.rowLimit : undefined,
}
writePersistedDrafts(storage, draft.projectRef, persisted)
}
@@ -186,12 +210,14 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
name = 'Untitled query',
sql = '',
source = createDefaultSourceBinding('database'),
rowLimit = DEFAULT_CELL_ROW_LIMIT,
}: {
id: string
projectRef: string
name?: string
sql?: string
source?: QuerySourceBinding
rowLimit?: number
}) => {
const draft = toDraft({
id,
@@ -201,6 +227,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
source: querySourceBindingSchema.parse(source),
sql,
updatedAt: Date.now(),
rowLimit,
},
})
@@ -237,11 +264,13 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
name,
source,
sql,
rowLimit,
}: {
id: string
name?: string
source?: QuerySourceBinding
sql?: string
rowLimit?: number
}) => {
const draft = state.drafts[id]
if (!draft) return
@@ -249,6 +278,8 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
const nextSource = source === undefined ? undefined : querySourceBindingSchema.parse(source)
if (nextSource !== undefined && nextSource._tag !== draft._tag) delete state.results[id]
const currentRowLimit = draft._tag === 'database' ? draft.rowLimit : undefined
state.drafts[id] = toDraft({
id,
projectRef: draft.projectRef,
@@ -257,6 +288,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
source: nextSource ?? toQuerySourceBinding(draft),
sql: sql ?? draft.uncheckedSql,
updatedAt: Date.now(),
rowLimit: rowLimit ?? currentRowLimit,
},
})
@@ -273,7 +305,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
const pending = pendingPersistence.get(id)
if (pending) clearTimeout(pending.timeout)
if (name !== undefined || source !== undefined) persist()
if (name !== undefined || source !== undefined || rowLimit !== undefined) persist()
else {
const timeout = setTimeout(persist, EXPLORER_QUERY_PERSIST_DELAY)
pendingPersistence.set(id, { timeout, persist })