mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-02 15:35:51 +08:00
fix(tables): reviewer + regression + per-LOC audit findings
Cursor review round (5 findings) + regression audit + per-LOC audit: - Presence roster snapshot now KEEPS the cell we already hold for a known socket, so a join/leave broadcast can't revert a fresher CELL_SELECTION delta. - Reset the selection throttle on table switch (was unmount-only), so a pending selection for table A can't flush into table B's room after a switch. - Metadata writes (column widths, display) use a new lightweight 'metadata' signal that refetches only the definition — a resize no longer forces peers to refetch rows. - Overlay re-measures on row add/remove/reorder via a tbody childList MutationObserver (a live refetch moves cells without a scroll/resize). - Document the actor self-refetch create caveat (scrolled multi-page insert) accurately. - isCellRef narrows to a partial instead of casting to the full type then re-checking; drop a redundant mount measure() (the layout effect covers it); text-[11px]→text-xs.
This commit is contained in:
@@ -21,10 +21,9 @@ const MAX_CELL_ID_LENGTH = 200
|
||||
const tableRoom = (tableId: string): RoomRef => ({ type: ROOM_TYPES.TABLE, id: tableId })
|
||||
|
||||
function isCellRef(value: unknown): value is TableCellRef {
|
||||
const ref = value as TableCellRef | null
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const ref = value as { rowId?: unknown; columnId?: unknown }
|
||||
return (
|
||||
typeof ref === 'object' &&
|
||||
ref !== null &&
|
||||
typeof ref.rowId === 'string' &&
|
||||
ref.rowId.length <= MAX_CELL_ID_LENGTH &&
|
||||
typeof ref.columnId === 'string' &&
|
||||
|
||||
@@ -7,7 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { TableMetadata } from '@/lib/table'
|
||||
import { updateTableMetadata } from '@/lib/table'
|
||||
import { signalTableSchemaChanged } from '@/lib/table/events'
|
||||
import { signalTableMetadataChanged } from '@/lib/table/events'
|
||||
import { accessError, checkAccess } from '@/app/api/table/utils'
|
||||
|
||||
const logger = createLogger('TableMetadataAPI')
|
||||
@@ -49,7 +49,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR
|
||||
validated.metadata,
|
||||
table.metadata as TableMetadata | null
|
||||
)
|
||||
signalTableSchemaChanged(tableId)
|
||||
signalTableMetadataChanged(tableId)
|
||||
|
||||
return NextResponse.json({ success: true, data: { metadata: updated } })
|
||||
} catch (error) {
|
||||
|
||||
+10
-2
@@ -132,18 +132,26 @@ export function RemoteSelectionOverlay({
|
||||
}
|
||||
const handleLeave = () => setHoveredSocketId(null)
|
||||
|
||||
measure()
|
||||
// No measure() here — the re-measure layout effect below runs on mount and whenever
|
||||
// `measure` changes (it depends on `scrollElement`), so it already covers the initial
|
||||
// and scroll-element-changed measures without a redundant pass.
|
||||
scrollEl.addEventListener('scroll', schedule, { passive: true })
|
||||
scrollEl.addEventListener('pointermove', handleMove, { passive: true })
|
||||
scrollEl.addEventListener('pointerleave', handleLeave)
|
||||
const resizeObserver = new ResizeObserver(schedule)
|
||||
resizeObserver.observe(scrollEl)
|
||||
// Re-measure when rows are added/removed/reordered/virtualized (a live refetch moves
|
||||
// cells without a scroll/resize) — childList only, so a cell-content edit doesn't fire.
|
||||
const tbody = scrollEl.querySelector('tbody')
|
||||
const rowObserver = new MutationObserver(schedule)
|
||||
if (tbody) rowObserver.observe(tbody, { childList: true })
|
||||
|
||||
return () => {
|
||||
scrollEl.removeEventListener('scroll', schedule)
|
||||
scrollEl.removeEventListener('pointermove', handleMove)
|
||||
scrollEl.removeEventListener('pointerleave', handleLeave)
|
||||
resizeObserver.disconnect()
|
||||
rowObserver.disconnect()
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
}
|
||||
}, [scrollElement, measure])
|
||||
@@ -171,7 +179,7 @@ export function RemoteSelectionOverlay({
|
||||
>
|
||||
{hoveredSocketId === box.socketId && (
|
||||
<span
|
||||
className='-top-[1.4em] absolute left-[-2px] whitespace-nowrap rounded-[3px] rounded-bl-none px-[5px] py-[1px] font-medium text-[#1a1a1a] text-[11px] leading-[1.4]'
|
||||
className='-top-[1.4em] absolute left-[-2px] whitespace-nowrap rounded-[3px] rounded-bl-none px-[5px] py-[1px] font-medium text-[#1a1a1a] text-xs leading-[1.4]'
|
||||
style={{ backgroundColor: box.color }}
|
||||
>
|
||||
{box.userName}
|
||||
|
||||
@@ -393,6 +393,15 @@ export function useTableEventStream({
|
||||
void queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
|
||||
scheduleRowsInvalidate()
|
||||
}
|
||||
// A collaborator changed UI metadata (widths, display): refetch only the
|
||||
// definition (exact) — no rows — mirroring the local metadata-mutation
|
||||
// invalidation, so a resize doesn't force every peer to refetch row data.
|
||||
else if (entry.event?.kind === 'metadata') {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: tableKeys.detail(tableId),
|
||||
exact: true,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to parse table event', { tableId, err })
|
||||
}
|
||||
|
||||
@@ -86,7 +86,20 @@ export function useTableRoom(tableId: string): UseTableRoomResult {
|
||||
retryTimer = setTimeout(join, JOIN_RETRY_BASE_MS * retries)
|
||||
}
|
||||
}
|
||||
const handlePresence = (users: TablePresenceUser[]) => setPresenceUsers(users ?? [])
|
||||
const handlePresence = (users: TablePresenceUser[]) => {
|
||||
// Take membership from the roster snapshot but keep the `cell` we already hold for
|
||||
// a known socket: the snapshot can be a beat behind the lower-latency CELL_SELECTION
|
||||
// deltas, so a blind replace could revert a fresher selection (it self-heals on the
|
||||
// peer's next delta, but the revert flicker is avoidable).
|
||||
setPresenceUsers((prev) => {
|
||||
const cellBySocket = new Map(prev.map((user) => [user.socketId, user.cell]))
|
||||
return (users ?? []).map((user) =>
|
||||
cellBySocket.has(user.socketId)
|
||||
? { ...user, cell: cellBySocket.get(user.socketId) }
|
||||
: user
|
||||
)
|
||||
})
|
||||
}
|
||||
const handleCellSelection = (data: TableCellSelectionBroadcast) => {
|
||||
// Patch the matching roster entry's selection. The peer is always already in
|
||||
// the roster: the server broadcasts their join (→ presence-update) before they
|
||||
@@ -125,11 +138,18 @@ export function useTableRoom(tableId: string): UseTableRoomResult {
|
||||
const trailingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingCellRef = useRef<TableCellSelection>(null)
|
||||
|
||||
// Reset the throttle when the table changes (or on unmount): a pending selection for
|
||||
// the table we're leaving must not flush into the next table's room after a switch.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (trailingTimerRef.current) clearTimeout(trailingTimerRef.current)
|
||||
if (trailingTimerRef.current) {
|
||||
clearTimeout(trailingTimerRef.current)
|
||||
trailingTimerRef.current = null
|
||||
}
|
||||
pendingCellRef.current = null
|
||||
lastEmitRef.current = 0
|
||||
},
|
||||
[]
|
||||
[tableId]
|
||||
)
|
||||
|
||||
const emitCellSelection = useCallback((cell: TableCellSelection) => {
|
||||
|
||||
@@ -127,6 +127,13 @@ export type TableEvent =
|
||||
kind: 'schema'
|
||||
tableId: string
|
||||
}
|
||||
| {
|
||||
/** A user changed UI table metadata (column widths, display settings) — not the
|
||||
* schema or rows. Signals collaborators to refetch only the table definition, so
|
||||
* the change shows live without a needless row refetch (e.g. on a column resize). */
|
||||
kind: 'metadata'
|
||||
tableId: string
|
||||
}
|
||||
|
||||
export interface TableEventEntry {
|
||||
eventId: number
|
||||
@@ -150,8 +157,12 @@ export async function appendTableEvent(event: TableEvent): Promise<TableEventEnt
|
||||
}
|
||||
|
||||
// The mutating client receives its own signal too (the stream carries no originator id)
|
||||
// and self-refetches — harmless, since signals fire after the write commits, so the
|
||||
// refetch returns the just-written state.
|
||||
// and self-refetches. Data-correct — signals fire after the write commits, so the refetch
|
||||
// returns the committed state, and in-flight edits are protected by the update/delete
|
||||
// hooks' cancelQueries. The one caveat is an own row-CREATE on a scrolled, multi-page
|
||||
// table, which can briefly reshuffle loaded pages (the create hook otherwise skips that
|
||||
// refetch); if that ever proves visible, stamp an originator id so the actor ignores its
|
||||
// own signal.
|
||||
|
||||
/**
|
||||
* Signal collaborators that a user changed row data so they refetch the rows live.
|
||||
@@ -170,6 +181,14 @@ export function signalTableSchemaChanged(tableId: string): void {
|
||||
void appendTableEvent({ kind: 'schema', tableId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal collaborators that a user changed UI table metadata (widths, display settings)
|
||||
* so they refetch only the definition — not the rows. Fire-and-forget.
|
||||
*/
|
||||
export function signalTableMetadataChanged(tableId: string): void {
|
||||
void appendTableEvent({ kind: 'metadata', tableId })
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest eventId assigned for a table, or 0 when the buffer is empty or
|
||||
* expired. Used by the stream route to tail from "now" when a client connects
|
||||
|
||||
Reference in New Issue
Block a user