From cfde341c3144b45325b8c0676af9d186b31ab658 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 18 Aug 2026 10:44:32 +0800 Subject: [PATCH] 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 ## 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. --- .../interfaces/Explorer/QueryTab.tsx | 5 +- apps/studio/state/explorer-query.test.ts | 91 +++++++++++++++++++ apps/studio/state/explorer-query.ts | 34 ++++++- 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/apps/studio/components/interfaces/Explorer/QueryTab.tsx b/apps/studio/components/interfaces/Explorer/QueryTab.tsx index ee94abf4ea8..00027068624 100644 --- a/apps/studio/components/interfaces/Explorer/QueryTab.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryTab.tsx @@ -19,7 +19,6 @@ export const QueryTab = () => { const querySnap = useExplorerQueryStateSnapshot() const roleImpersonationState = useLocalRoleImpersonationState() - const [rowLimit, setRowLimit] = useState(100) const [restoredQueryKey, setRestoredQueryKey] = useState() 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 })} /> ) } diff --git a/apps/studio/state/explorer-query.test.ts b/apps/studio/state/explorer-query.test.ts index e23f7f9fbdb..5a8c954e391 100644 --- a/apps/studio/state/explorer-query.test.ts +++ b/apps/studio/state/explorer-query.test.ts @@ -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) diff --git a/apps/studio/state/explorer-query.ts b/apps/studio/state/explorer-query.ts index ae1f1972c61..5e6f6cb6898 100644 --- a/apps/studio/state/explorer-query.ts +++ b/apps/studio/state/explorer-query.ts @@ -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 @@ -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 })