mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
## Context
If you've got a queued operation on a table, then switch tables, the
dashboard stalls for a few seconds before finally rendering the selected
table.
This was caused by an infinite loop in a useEffect calling
`reapplyOptimisticUpdates` due to `dataUpdatedAt` being in the
dependency array (which comes from react query)
Removing `dataUpdatedAt` would resolve the issue, but then it won't
solve the following comment in the code:
`// This ensures pending changes remain visible when switching tabs or
after data refresh`
## Changes involved
Opting to refactor the logic for rendering data in the Grid instead
- Avoid manipulating the data directly from the query client
- Manual changes to the query client are hard to track when debugging
- Ideally the data in the query client are exactly what is coming from
the API
- Instead just compute the row data if there's operations applied, and
render that in the grid
```
const baseRows = data?.rows ?? EMPTY_ARR
const rows = formatGridDataWithOperationValues({ operations, rows:
baseRows })
```
- Simplifies operations logic + data in react query remains as the
source of truth
- This also improves perceived performance, as previously we'd need to
invalidate the query client if we're removing any operation.
- A lot more apparent with the undo operation introduced
[here](https://github.com/supabase/supabase/pull/43957)
- Whereas now, we'll just revert back to whatever's in the query client
(and still do the invalidation behind the scenes) so things feel faster
241 lines
6.6 KiB
TypeScript
241 lines
6.6 KiB
TypeScript
import { isTableLike, type Entity } from 'data/table-editor/table-editor-types'
|
|
import type { Dictionary } from 'types'
|
|
|
|
import { isPendingAddRow, PendingAddRow, SupaRow } from '../types'
|
|
import {
|
|
EditCellContentOperation,
|
|
NewQueuedOperation,
|
|
QueuedOperation,
|
|
QueuedOperationType,
|
|
} from '@/state/table-editor-operation-queue.types'
|
|
|
|
interface EditCellKeyOperation extends Omit<
|
|
EditCellContentOperation,
|
|
'payload' | 'id' | 'timestamp'
|
|
> {
|
|
type: QueuedOperationType.EDIT_CELL_CONTENT
|
|
tableId: number
|
|
payload: {
|
|
columnName: string
|
|
rowIdentifiers: Dictionary<unknown>
|
|
}
|
|
}
|
|
|
|
export function generateTableChangeKey(
|
|
operation: NewQueuedOperation | EditCellKeyOperation
|
|
): string {
|
|
if (operation.type === QueuedOperationType.EDIT_CELL_CONTENT) {
|
|
const { columnName, rowIdentifiers } = operation.payload
|
|
const rowIdentifiersKey = Object.entries(rowIdentifiers)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([key, value]) => `${key}:${value}`)
|
|
.join('|')
|
|
return `${operation.type}:${operation.tableId}:${columnName}:${rowIdentifiersKey}`
|
|
}
|
|
|
|
if (operation.type === QueuedOperationType.ADD_ROW) {
|
|
return `${operation.type}:${operation.tableId}:${operation.payload.tempId}`
|
|
}
|
|
|
|
if (operation.type === QueuedOperationType.DELETE_ROW) {
|
|
const { rowIdentifiers } = operation.payload
|
|
const rowIdentifiersKey = Object.entries(rowIdentifiers)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([key, value]) => `${key}:${value}`)
|
|
.join('|')
|
|
return `${operation.type}:${operation.tableId}:${rowIdentifiersKey}`
|
|
}
|
|
|
|
// Exhaustive check - TypeScript will error if we miss a case
|
|
const _exhaustiveCheck: never = operation
|
|
throw new Error(`Unknown operation type: ${(_exhaustiveCheck as { type: string }).type}`)
|
|
}
|
|
|
|
export function rowMatchesIdentifiers(
|
|
row: Dictionary<unknown>,
|
|
rowIdentifiers: Dictionary<unknown>
|
|
): boolean {
|
|
const identifierEntries = Object.entries(rowIdentifiers)
|
|
if (identifierEntries.length === 0) return false
|
|
return identifierEntries.every(([key, value]) => row[key] === value)
|
|
}
|
|
|
|
export function removeRow(rows: SupaRow[], rowIdentifiers: Dictionary<unknown>): SupaRow[] {
|
|
return rows.filter((row) => !rowMatchesIdentifiers(row, rowIdentifiers))
|
|
}
|
|
|
|
interface QueueCellEditParams {
|
|
queueOperation: (operation: NewQueuedOperation) => void
|
|
tableId: number
|
|
table: Entity
|
|
row: SupaRow
|
|
rowIdentifiers: Dictionary<unknown>
|
|
columnName: string
|
|
oldValue: unknown
|
|
newValue: unknown
|
|
enumArrayColumns?: string[]
|
|
}
|
|
|
|
export function queueCellEditWithOptimisticUpdate({
|
|
queueOperation,
|
|
tableId,
|
|
table,
|
|
row,
|
|
rowIdentifiers: callerRowIdentifiers,
|
|
columnName,
|
|
oldValue,
|
|
newValue,
|
|
enumArrayColumns,
|
|
}: QueueCellEditParams) {
|
|
// Updated row identifiers to include __tempId for pending add rows so edits merge into ADD_ROW operation
|
|
const rowIdentifiers: Dictionary<unknown> = { ...callerRowIdentifiers }
|
|
if (isPendingAddRow(row)) {
|
|
rowIdentifiers.__tempId = row.__tempId
|
|
}
|
|
|
|
// Queue the operation
|
|
queueOperation({
|
|
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
|
tableId,
|
|
payload: {
|
|
rowIdentifiers,
|
|
columnName,
|
|
oldValue,
|
|
newValue,
|
|
table,
|
|
enumArrayColumns,
|
|
},
|
|
})
|
|
}
|
|
|
|
interface QueueRowAddParams {
|
|
queueOperation: (operation: NewQueuedOperation) => void
|
|
tableId: number
|
|
table: Entity
|
|
rowData: PendingAddRow
|
|
enumArrayColumns?: string[]
|
|
}
|
|
|
|
export function queueRowAddWithOptimisticUpdate({
|
|
queueOperation,
|
|
tableId,
|
|
table,
|
|
rowData,
|
|
enumArrayColumns,
|
|
}: QueueRowAddParams) {
|
|
// Generate unique idx and tempId for this pending row
|
|
const idx = -Date.now()
|
|
const tempId = String(idx)
|
|
|
|
// Queue the operation
|
|
queueOperation({
|
|
type: QueuedOperationType.ADD_ROW,
|
|
tableId,
|
|
payload: {
|
|
tempId,
|
|
rowData,
|
|
table,
|
|
enumArrayColumns,
|
|
},
|
|
})
|
|
}
|
|
|
|
export const formatGridDataWithOperationValues = ({
|
|
operations,
|
|
rows,
|
|
}: {
|
|
operations: QueuedOperation[]
|
|
rows: SupaRow[]
|
|
}) => {
|
|
const formattedRows = rows.slice()
|
|
|
|
operations.forEach((op) => {
|
|
if (op.type === QueuedOperationType.EDIT_CELL_CONTENT) {
|
|
const { rowIdentifiers, columnName, newValue } = op.payload
|
|
const rowMatches = rows.find((row) => rowMatchesIdentifiers(row, rowIdentifiers))
|
|
if (rowMatches) {
|
|
formattedRows[rowMatches.idx] = { ...rowMatches, [columnName]: newValue }
|
|
}
|
|
} else if (op.type === QueuedOperationType.ADD_ROW) {
|
|
const { tempId, rowData } = op.payload
|
|
const idx = Number(tempId)
|
|
|
|
// Check if row with this tempId already exists
|
|
const existingIndex = formattedRows.findIndex(
|
|
(row) => isPendingAddRow(row) && row.__tempId === tempId
|
|
)
|
|
if (existingIndex >= 0) {
|
|
// Update existing row in place
|
|
formattedRows[existingIndex] = {
|
|
...formattedRows[existingIndex],
|
|
...rowData,
|
|
__tempId: tempId,
|
|
}
|
|
} else {
|
|
const newRow: PendingAddRow = { ...rowData, idx, __tempId: tempId }
|
|
formattedRows.unshift(newRow)
|
|
}
|
|
} else if (op.type === QueuedOperationType.DELETE_ROW) {
|
|
const { rowIdentifiers } = op.payload
|
|
const rowMatches = rows.find((row) => rowMatchesIdentifiers(row, rowIdentifiers))
|
|
if (rowMatches) {
|
|
formattedRows[rowMatches.idx] = { ...rowMatches, __isDeleted: true }
|
|
}
|
|
}
|
|
})
|
|
|
|
return formattedRows
|
|
}
|
|
|
|
interface QueueRowDeletesParams {
|
|
rows: SupaRow[]
|
|
table: Entity
|
|
queueOperation: (operation: NewQueuedOperation) => void
|
|
projectRef: string | undefined
|
|
}
|
|
|
|
/**
|
|
* Queue multiple row delete operations with optimistic updates.
|
|
* Caller is responsible for checking if queue mode is enabled before calling.
|
|
*/
|
|
export function queueRowDeletesWithOptimisticUpdate({
|
|
rows,
|
|
table,
|
|
queueOperation,
|
|
projectRef,
|
|
}: QueueRowDeletesParams): void {
|
|
// [Ali] We can handle these better in the future
|
|
// right now this is a pretty abnormal case of this occurring
|
|
if (!projectRef) {
|
|
console.error('Cannot queue row deletes: projectRef is required')
|
|
return
|
|
}
|
|
|
|
if (!isTableLike(table)) {
|
|
console.error('Cannot queue row deletes: table must be a TableLike entity')
|
|
return
|
|
}
|
|
|
|
if (table.primary_keys.length === 0) {
|
|
console.error('Cannot queue row deletes: table has no primary keys')
|
|
return
|
|
}
|
|
|
|
for (const row of rows) {
|
|
const rowIdentifiers: Record<string, unknown> = {}
|
|
table.primary_keys.forEach((pk) => {
|
|
rowIdentifiers[pk.name] = row[pk.name]
|
|
})
|
|
|
|
queueOperation({
|
|
type: QueuedOperationType.DELETE_ROW,
|
|
tableId: table.id,
|
|
payload: {
|
|
rowIdentifiers,
|
|
originalRow: row,
|
|
table,
|
|
},
|
|
})
|
|
}
|
|
}
|