HellDots

Events & callbacks

The ChangeEvent union, the ten specific callbacks that mirror it, and the five that report something other than a change.

Every mutation is available two ways: as one stream, or as a specific callback. They carry exactly the same events at exactly the same moments, with the same metadata. Subscribe either way, or both.

createCommentOverlay({
  onChange: (event) => api.post('/helldots-events', event),
});

A handler that throws is caught and warned about. It never rolls back the mutation that emitted it.

ChangeEvent

A discriminated union on type. Switch on it and TypeScript narrows the rest.

type ChangeEvent =
  | ({ type: 'comment:created'; comment: SerializedComment } & ChangeMeta)
  | ({ type: 'comment:edited'; comment: SerializedComment } & ChangeMeta)
  | ({ type: 'comment:deleted'; id: CommentId } & ChangeMeta)
  | ({ type: 'comment:status-changed'; comment: SerializedComment } & StatusChangeMeta)
  | ({ type: 'comment:updated'; comment: SerializedComment } & UpdateMeta)
  | ({ type: 'comment:anchor-lost'; comment: SerializedComment } & ChangeMeta)
  | ({ type: 'reply:added'; comment: SerializedComment; reply: CommentReply } & ChangeMeta)
  | ({ type: 'reply:edited'; comment: SerializedComment; reply: CommentReply } & ChangeMeta)
  | ({ type: 'reply:deleted'; comment: SerializedComment; reply: CommentReply } & ChangeMeta)
  | ({ type: 'reaction:toggled'; comment: SerializedComment; reply: CommentReply | null } & ChangeMeta);

The metadata is flattened onto the event, rather than nested under a meta key — event.origin, event.from, event.to, event.field.

The metadata

meta.origin

Present on every event.

ValueMeans
"user"Somebody acting inside the widget — a marker, the thread popover, the inbox
"host"Your own code calling a method

The widget's UI drives the very same public methods you do, so this is the only thing that tells the two apart. It exists for multi-user apps: applying a change that arrived over a socket means calling the emitting method, so without it every host has to wrap its own writes in a flag.

onChange: (event) => {
  if (event.origin === 'host') return; // our own write, echoed back
  socket.emit('helldots', event);
};

comment:anchor-lost is always "host", including the repeat every notifyNavigation() produces — so the same guard silences those too.

StatusChangeMeta

interface StatusChangeMeta extends ChangeMeta {
  from: CommentStatus;
  to: CommentStatus;
}

Both ends of the move, so "reopened" and "resolved" are told apart without diffing against a previous copy.

UpdateMeta

type UpdateMeta = ChangeMeta &
  (
    | { field: 'type'; from: CommentType | null; to: CommentType | null }
    | { field: 'priority'; from: CommentPriority | null; to: CommentPriority | null }
    | { field: 'tags'; from: string[]; to: string[] }
  );

Discriminated on field, so narrowing gives correctly typed from and to for each of the three.

The ten change callbacks

Every one ends with a meta argument, so an existing handler that ignores it keeps working unchanged.

CallbackFires when
onCommentCreated(comment, meta)A new comment is saved
onCommentEdited(comment, meta)A comment's text is rewritten
onCommentDeleted(id, meta)A comment is removed
onCommentStatusChanged(comment, meta)Status moves along the lifecycle — meta is StatusChangeMeta
onCommentUpdated(comment, meta)Type, priority or tags change — meta is UpdateMeta
onAnchorLost(comment, meta)A comment could not be re-anchored
onReplyAdded(comment, reply, meta)A reply is added to any comment
onReplyEdited(comment, reply, meta)A reply's text is rewritten
onReplyDeleted(comment, reply, meta)A reply is removed
onReactionToggled(comment, reply, meta)A reaction is added or removed — reply is null at the root

onCommentStatusChanged

onCommentStatusChanged: (comment, { from, to }) => {
  if (from === 'resolved') notify(`${comment.author} reopened this`);
};

onCommentUpdated

onCommentUpdated: (comment, meta) => {
  if (meta.field === 'priority' && meta.to === 'high') page(comment);
  if (meta.field === 'tags') reindex(comment.id, meta.to);
};

onAnchorLost

Fired for each comment that could not be re-anchored — by loadComments, and again by every notifyNavigation that lands somewhere its element does not exist. Those repeats always carry origin: "host".

onAnchorLost: (comment, meta) => {
  if (meta.origin === 'host') return; // a repeat from a navigation
  analytics.track('helldots:anchor-lost', { page: comment.page });
};

A jump in these after a deploy means a refactor moved the elements people had been commenting on.

The five that are not changes

onReady

onReady?: (overlay: CommentOverlay) => void;

The widget has mounted and every method is safe to drive. It receives the instance because, when the document is already parsed, the mount happens inside the constructor — before createCommentOverlay() has returned anything to assign. The right place to loadComments().

onError

onError?: (error: unknown, context: ErrorContext) => void;

Failures the widget survives but you would otherwise only find in the console. The console warning stays either way.

contextWhat happened
"capture"A screenshot failed to render; the comment saves without one
"storage"localStorage could not be written; this browser's copy now diverges
"load"A record handed to loadComments was malformed and was skipped
"link"An onCommentRequested handler threw or rejected
"transform"A transformScreenshot handler failed; the data URL was kept
onError: (error, context) => {
  if (context === 'storage') toast.warn('Comments could not be saved locally.');
  logger.warn({ context }, String(error));
};

onCommentRequested

onCommentRequested?: (id: CommentId) => void | Promise<unknown>;

A "Copy link" URL points at a comment the widget does not hold — once per id, not once per attempt. Return a promise and the link is retried once it settles. See deep links.

onCommentModeChanged

onCommentModeChanged?: (active: boolean) => void;

Comment mode turned on or off, however it was flipped — the toolbar button, the keyboard shortcut, the inbox empty state, or the automatic switch-off after a comment is saved.

The shortcut is the reason this exists: the host never sees that keystroke, so an app that needs to stand down while somebody is picking an element — pause a carousel, disable its own drag-and-drop, dim a layer — has no other signal.

onCommentOpened

onCommentOpened?: (comment: SerializedComment) => void;

Somebody opened a comment's full thread, from its marker or the inbox detail — the only two places the replies are readable. It does not fire when the inbox merely re-renders.

This is what an unread count is built on. HellDots keeps no read state of its own, because whose "read" it is depends on an identity only you can persist.

onCommentOpened: (comment) => api.post(`/comments/${comment.id}/read`);

On this page