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:
Waleed Latif
2026-07-24 21:13:28 -07:00
parent 672c200449
commit b8f28b04bf
6 changed files with 67 additions and 12 deletions
+2 -3
View File
@@ -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) {
@@ -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) => {
+21 -2
View File
@@ -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