fix(tables): stop insert-row flicker and return order_key from rows list (#4918)

* fix(tables): stop insert-row flicker and return order_key from rows list

The rows insert flicker came from useCreateTableRow.onSettled invalidating
tableKeys.detail(tableId), which prefix-matches the nested rowsRoot rows query
and forces an un-cancelled refetch on every insert. A late offset refetch could
resolve after the optimistic splice and clobber freshly-inserted rows.

- invalidate detail with exact:true (+ lists) so the count surfaces refresh
  without cascading into the rows query
- compare order keys bytewise in reconcileCreatedRow to match the server's
  COLLATE "C" ordering and fitsAfter (was localeCompare)
- include order_key in the GET /rows list response; it was dropped in the
  route mapping, so the client never saw keys and reconcileCreatedRow always
  fell back to the position path

* fix(tables): scope optimistic insert splice to the default-order view

reconcileCreatedRow patched every rows query under rowsRoot, but its
orderKey/position heuristic only matches the unfiltered, unsorted server order.
Under a filter or column sort the splice could show wrong rows, wrong order, or
an inflated totalCount, and without the prior rowsRoot refetch it persisted
until the query went stale.

- patch only the default-order rows queries (filter and sort both absent)
- refetch the filtered/sorted variants on insert; active ones update now,
  inactive ones on next view. The default view stays optimistic (no flicker)
- the find/write subtrees are excluded from the splice (different shape)
This commit is contained in:
Theodore Li
2026-06-09 01:12:45 -04:00
committed by GitHub
parent 05408fd1ce
commit efeacb9e22
2 changed files with 77 additions and 5 deletions
@@ -284,6 +284,7 @@ export const GET = withRouteHandler(
data: r.data,
executions: r.executions,
position: r.position,
orderKey: r.orderKey ?? undefined,
createdAt:
r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
updatedAt:
+76 -5
View File
@@ -177,6 +177,26 @@ function invalidateRowCount(queryClient: ReturnType<typeof useQueryClient>, tabl
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
}
/**
* Invalidate only the row-count surfaces — the table detail and the tables
* list, both of which carry the unfiltered `rowCount`. Deliberately leaves
* `rowsRoot` (the rows infinite query) untouched so an offset-paginated refetch
* can't resolve late and clobber rows already spliced in optimistically. Use
* for inserts, where `reconcileCreatedRow` is the source of truth for the rows
* cache and its `totalCount`.
*
* `rowsRoot` is nested under `detail` (`[...detail(tableId), 'rows']`), so the
* detail invalidation MUST be `exact` — a prefix match would cascade into the
* rows queries and trigger the very refetch this helper exists to avoid.
*/
function invalidateRowCountSurfaces(
queryClient: ReturnType<typeof useQueryClient>,
tableId: string
) {
queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true })
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
}
function invalidateTableSchema(queryClient: ReturnType<typeof useQueryClient>, tableId: string) {
queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) })
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
@@ -585,13 +605,27 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext)
// prior executions, so the stamped set is the full delta).
const stampedCount = countNewlyInFlight({}, stamped.executions ?? {})
if (stampedCount > 0) bumpRunState(queryClient, tableId, { [row.id]: stampedCount })
// `reconcileCreatedRow` only patches the default-order view. Filtered /
// column-sorted rows queries can't be reconciled from that heuristic
// (membership, sort position, and `totalCount` are query-specific), so
// refetch them — active ones update now, inactive ones on next view. The
// default view stays optimistic, so the common case never refetches.
queryClient.invalidateQueries({
queryKey: tableKeys.rowsRoot(tableId),
exact: false,
predicate: (query) => !isDefaultOrderRowsQuery(query.queryKey),
})
},
onError: (error) => {
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
onSettled: () => {
invalidateRowCount(queryClient, tableId)
// `reconcileCreatedRow` (onSuccess) is the source of truth for the rows
// cache + its `totalCount`; only refresh the count surfaces here so a late
// offset refetch can't clobber freshly-inserted rows (insert-flicker).
invalidateRowCountSurfaces(queryClient, tableId)
},
})
}
@@ -630,12 +664,35 @@ function patchCachedRows(
)
}
/**
* A cached rows query whose ordering matches {@link reconcileCreatedRow}'s
* orderKey/position heuristic: the default view with no active filter or sort.
* Filtered or column-sorted variants encode a non-null `filter`/`sort` in their
* params key — their membership, order, and `totalCount` are query-specific, so
* an optimistic splice can't be trusted there (they're refetched instead). The
* `find`/`write` subtrees aren't row-list data and never match.
*/
function isDefaultOrderRowsQuery(queryKey: readonly unknown[]): boolean {
if (queryKey.includes('find') || queryKey.includes('write')) return false
const last = queryKey[queryKey.length - 1]
if (typeof last !== 'string') return false
try {
const params = JSON.parse(last) as { filter?: unknown; sort?: unknown }
return params.filter == null && params.sort == null
} catch {
return false
}
}
/**
* Splice a server-returned new row into the paginated row cache. Bumps the
* `position` of any cached row at or past the new row's position, then inserts
* the row into the overlapping page (or appends to the last page when the
* position lies past everything fetched). `onSettled` invalidation reconciles
* drift after the next refetch.
* position lies past everything fetched).
*
* Scoped to the default-order rows queries only — the orderKey/position
* heuristic matches the unfiltered, unsorted server order, not an active filter
* or column sort. Filtered/sorted queries are refetched by the caller.
*/
function reconcileCreatedRow(
queryClient: ReturnType<typeof useQueryClient>,
@@ -643,7 +700,11 @@ function reconcileCreatedRow(
row: TableRow
) {
queryClient.setQueriesData<InfiniteData<TableRowsResponse, number>>(
{ queryKey: tableKeys.rowsRoot(tableId), exact: false },
{
queryKey: tableKeys.rowsRoot(tableId),
exact: false,
predicate: (query) => isDefaultOrderRowsQuery(query.queryKey),
},
(old) => {
if (!old) return old
if (old.pages.some((p) => p.rows.some((r) => r.id === row.id))) return old
@@ -654,9 +715,19 @@ function reconcileCreatedRow(
// path so un-keyed rows aren't yanked to the front by an empty-string sort.
const byKey =
row.orderKey != null && old.pages.every((p) => p.rows.every((r) => r.orderKey != null))
// Compare order keys bytewise to match the server's `COLLATE "C"` ordering
// and the `>=` checks in `fitsAfter` — `localeCompare` is locale-aware and
// would place the new row in a different slot than the server (e.g. an
// uppercase-prefixed key), leaving it visibly misordered until next reload.
const sortRows = (rows: TableRow[]) =>
byKey
? [...rows].sort((a, b) => (a.orderKey as string).localeCompare(b.orderKey as string))
? [...rows].sort((a, b) =>
(a.orderKey as string) < (b.orderKey as string)
? -1
: (a.orderKey as string) > (b.orderKey as string)
? 1
: 0
)
: [...rows].sort((a, b) => a.position - b.position)
const fitsAfter = (last: TableRow | undefined) =>
last === undefined ||