developer guide
Live Presence and Cursors in a Data Grid
Everyone in the grid can see who else is there and where they are, each in their own colour, so a second editor is visible before the collision rather than after it.
Developer guide › Collaboration and comments › Live Presence and Cursors in a Data Grid
Collaborative presence
Who else is looking at this grid, and what they are doing.
An in-memory provider, which is all the interface requires
const handlers = new Map();
const providerFor = (id) => ({
subscribe(fn) { handlers.set(id, fn); return () => handlers.delete(id); },
publish(state) {
for (const [peer, fn] of handlers) if (peer !== id) fn(state);
},
});
Presence carries intent, never values. This is the line that matters most. A peer's committed edit reaches the grid as data, through the channel you already use for data. Presence is throttled, lossy and ephemeral by design, so a value carried on it is a value that can be dropped, and that is the class of bug that appears once a month in production and cannot be reproduced on demand.
Positions are row keys, resolved against your view at render time. Peers sort and filter independently, so index 12 is a different record on every screen. Publishing an index would put a colleague's cursor on an unrelated row the moment either of you sorted. The cost of this is real: the grid resolves a key to a position rather than reading one, and it is the difference between the feature working and the feature lying.
Idle is measured from when a message arrived, not from what it says. Clocks between clients disagree by seconds routinely and by minutes occasionally. Keying idle detection on the sender's timestamp means a peer with a fast clock never goes idle and one with a slow clock is idle immediately. The sender's timestamp is kept for inspection and decides nothing.
Publishing throttles rather than debounces. A debounce sends nothing until the user stops moving, so every peer sees a cursor that teleports on pause instead of moving. The leading edge goes out immediately and the trailing edge carries wherever it settled.
Publishing stops while the tab is hidden. Nobody is moving that cursor. Receiving continues, so coming back to the tab shows the current state rather than an empty roster filling in slowly.
A peer's cursor is dashed; your focus ring is solid. The distinction has to
be in the kind of line, not only its colour. Colour alone fails for anyone who cannot separate
two hues and fails for everyone at a glance, and the palette originally contained the
exact value of --lattice-focus-color, so the first peer assigned drew a cursor
identical to the local user's own selection. An active edit is solid and tinted, because an
edit is not a cursor and those two must not be confused with each other either.
Nothing is inserted into the grid. Every treatment is an attribute and a custom property written onto a cell that already exists, drawn with an outline and a pseudo-element. That is what keeps presence from shifting layout, covering an in-cell chart or swallowing a click: none of which survives an implementation that appends overlay elements. The overlay layer is pointer-transparent and presence deliberately does not opt back in; only the roster does, because it is a control.
The roster is the part people use. More than the cursors, in practice. It carries the name as well as the colour, because colour alone is not a signal everyone can read, and it reports peers whose rows are not in your view rather than omitting them, an absent peer reads as a disconnection that has not happened.
A parked cursor does not fade. The label does, after a couple of seconds, because permanent labels over a dense grid are unreadable. The border stays, dims at idle, and goes only on removal: a cursor that vanished while its owner was still connected would report exactly the thing this feature exists to prevent.
Locking is advisory, and the documentation says so because a developer who
believes otherwise will skip the conditional write. Presence is throttled and can
arrive out of order; two clients can enter an edit in the same instant. What actually resolves
the conflict is the conditional write in edit.commit, which returns a conflict
and rolls the optimistic edit back. Locking narrows the window; the conditional write at commit closes it. A stale
claim is disregarded after a much shorter window than peer removal, because a lock held by
someone who shut their laptop blocks a cell nobody is editing.
Outside its scope: transport, reconnection or authentication; operational transform or CRDT merging; presence history; text-level cursors inside a cell editor; follow mode; chat.