StableTable

stable-table.mjs

Diff-and-patch helper for <tbody> re-renders. Preserves <tr> identity across renders so :hover and focus survive frequent updates. Defers structural updates (insertions, removals, reorderings) while the user is hovering or focused inside the body — applies them when the user leaves, with an exception for user actions: a click inside the body opens a one-second window in which updates apply straight away (the user is acting; they expect to see fresh state). While interacting, column widths are also frozen (inline-styled on the thead row — or, in a table without a thead, on the body's first row — plus table-layout: fixed) so an in-place cell update can't shift the layout under the cursor; the inline styles are put back as they were on leave.

The demos use passive timers to simulate updates from WebSocket messages, polling, or background events. A button-driven update does not demonstrate deferral: clicking outside the table ends hover, and clicking inside it temporarily allows updates.

API

ExportSignatureDescription
StableTable new StableTable({ tbody }) Class. Wraps a <tbody> with stable-id reconciliation. Extends EventTarget; emits 'change' when pending state flips.
.update (rows: Array<{id, cells}>) => void Reconcile rows by id. cells is the HTML for the row's <td> contents. Same id-sequence as last call → in-place patch; different sequence → defer if interacting, apply otherwise.
.mark_action () => void Open a 1-second window during which every update bypasses the hover/focus defer. Auto-called on tbody clicks; expose for external triggers.
.has_pending () => boolean true when an update is deferred behind interaction.
.flush () => void Apply any deferred update immediately.
.destroy () => void Release the column-width freeze and remove the internal listeners (call from disconnectedCallback).
bind_pending_updates_notification (stable_table, { id, notifications }) => () => void Show a notification with an action to apply the update while the table has a deferred update. Returns a cleanup function.

Each row's cells is written into tr.innerHTML as markup, so it must be component-authored HTML: escape any user or server text you interpolate into it with esc() from esc.mjs. Embed the row id elsewhere via the data-row-id attribute the helper sets — handy for delegated event listeners.

1. Live updates — hover keeps rows from shifting

A timer pushes a fresh row sequence every ~1.2 s, simulating a poll or websocket. Start the sync, then hover any row: the row stays put while structural updates wait. Move the cursor outside the table and the queued state applies. The pill flips to pending while a structural update is held back.

IdLabelScore

no pending

Show code

2. Frequent updates with stable row IDs

When the id sequence is unchanged, update patches each row's innerHTML per cell — no structural work, no defer. Hover a row while the timer ticks: the row's contents keep updating, but neither its position nor the column widths shift, even though tick #9 → tick #10 → tick #100 would reflow an auto-layout table without intervention.

Column-width stability comes from StableTable itself: on pointer enter / focus in, it measures the current column widths, inlines them on the thead row, and switches the table to table-layout: fixed. On leave it restores both. The natural-state CSS the caller sees is unchanged — the inline styles only exist while the user is interacting.

IdStatusTick

Show code

3. bind_pending_updates_notification — apply updates from the notification

Same passive sync as Demo 1, plus a binding that surfaces a short notification while a structural update is queued. Use its action to apply the update without leaving the row. The notification clears when the cursor leaves the table (pointerleave auto-flushes).

IdLabel

Show code

4. Row actions apply updates immediately

The everyday pattern. A background timer adds a row every 1.5 s; while you're hovering, those additions queue (pill flips to pending update). Click Remove on the row you're reading and the click does not wait: any tbody click auto-calls mark_action(), so the next update bypasses the defer. The hovered row goes away — and any queued background additions apply with it — without you having to leave the table.

IdLabel

no pending

mark_action() is also exported for the rarer case where a user action triggered from outside the tbody produces an async response that arrives after the cursor has returned to the table — e.g. a Refresh button beside the table whose fetch resolves ~600 ms later. Call st.mark_action() when the click fires; every update within the next second bypasses the defer.

Show code