StableTable
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.
| Export | Signature | Description |
|---|---|---|
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.
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.
| Id | Label | Score |
|---|
no pending
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.
| Id | Status | Tick |
|---|
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).
| Id | Label |
|---|
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.
| Id | Label |
|---|
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.