Real-time & multi-user
Sync comments over a socket without echoing your own writes back into an infinite loop.
Once more than one person is looking at the same page, comments have to travel. The pattern is always the same shape — send local changes up, apply remote changes down — and it has exactly one trap.
The echo
Applying a change that arrived over a socket means calling the same public method the UI calls. That method emits. The emission goes to your handler. Your handler sends it to the server. The server broadcasts it. Forever.
meta.origin is what breaks the loop:
const overlay = createCommentOverlay({
user: { name: session.name, id: session.userId },
onChange: (event) => {
if (event.origin === 'host') return; // our own write, echoed back
socket.emit('helldots', event);
},
});
socket.on('helldots', (event) => {
switch (event.type) {
case 'comment:created':
overlay.loadComments([event.comment]);
break;
case 'comment:status-changed':
overlay.setCommentStatus(event.comment.id, event.comment.status);
break;
case 'comment:deleted':
overlay.deleteComment(event.id);
break;
}
});origin | Means |
|---|---|
"user" | Somebody acting inside the widget — a marker, the thread popover, the inbox |
"host" | Your own code calling a method |
The inbox and the thread popover drive the very same public methods you do, so this is the only thing that tells the two apart. Without it, a host has to wrap every one of its own writes in a flag — which is the library's job, not yours.
anchor-lost is always host
comment:anchor-lost carries origin: "host" unconditionally, including the
repeat that every notifyNavigation() produces for a comment whose element is
not on the new page. The same guard silences those.
What moved, not just that something did
Two events carry the transition, so you never have to diff against a previous copy you were keeping for the purpose.
createCommentOverlay({
onCommentStatusChanged: (comment, { from, to }) => {
if (from === 'resolved') notify(`${comment.author} reopened this`);
if (to === 'resolved') celebrate(comment);
},
onCommentUpdated: (comment, meta) => {
if (meta.field === 'priority' && meta.to === 'high') page(comment);
},
});comment:updated is discriminated on field, so narrowing it gives you
correctly typed from and to for each of the three cases — string[] for
tags, CommentPriority | null for priority.
Re-applying a value a comment already holds is a no-op: no event, no write. That makes the down-stream idempotent for free.
One stream or ten callbacks
createCommentOverlay({
onChange: (event) => api.post('/helldots-events', event),
});ChangeEvent is a discriminated union — switch on event.type and TypeScript
narrows the payload. The ten specific callbacks carry exactly the same events at
exactly the same moments, with the same metadata; subscribe either way, or both.
A handler that throws is caught and warned about. It never rolls back the mutation that emitted it.
Reconciling on reconnect
loadComments() replaces by id but never removes, so a socket that missed a
deletion leaves a ghost. After a gap, reset rather than merge:
socket.on('reconnect', async () => {
overlay.clearComments();
overlay.loadComments(await api.get(`/comments?page=${location.pathname}`));
});clearComments() fires no per-comment callbacks — it is a reset, and echoing a
hundred deletions back to the server is exactly what you do not want here.
Unread counts
HellDots keeps no read state of its own, because whose "read" it is depends on an
identity only you can persist. onCommentOpened is the signal to build one on:
createCommentOverlay({
onCommentOpened: (comment) => api.post(`/comments/${comment.id}/read`),
});It fires when a thread is actually read — from its marker or from the inbox detail, the only two places the replies are visible. It does not fire when the inbox merely re-renders.
Standing down while somebody points
The keyboard shortcut never reaches your code, so an app that has to get out of the way while a user is picking an element has no other signal:
createCommentOverlay({
onCommentModeChanged: (active) => {
carousel.paused = active; // your drag-and-drop would fight the picker
dropzone.disabled = active;
},
});It fires however the mode was flipped — the toolbar button, the shortcut, the inbox empty state, or the automatic switch-off after a comment is saved.
Multi-tab, without a socket
persistence: "localStorage" assumes one active tab per page. Writes from
another tab are preserved on the next sync, but two tabs editing the same
comment resolve last-write-wins, and a comment deleted in one tab can reappear if
another tab still holding it in memory saves afterwards.
If real multi-tab editing matters, persist through the callbacks instead — even
if "the backend" is a BroadcastChannel:
const channel = new BroadcastChannel('helldots');
const overlay = createCommentOverlay({
onChange: (event) => {
if (event.origin === 'host') return;
channel.postMessage(event);
},
});
channel.onmessage = ({ data }) => applyRemote(overlay, data);