mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-25 09:34:44 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user