Skip to content

Issue detail live updates

Internal write-up of the two live updates that keep the issue detail v2 current: issueJournalsChanged and issueAttributesChanged. The generic how-to is in Real-time updates.

Frontend rules

  • Subscribe once per view, from its root component, never inside a component that repeats.
  • Guard the store against out-of-order responses. useCommentsStore keeps an increasing requestId and applies only the newest response.
  • Guard open editors: a field whose editor is open warns instead of reloading under the user. A control that holds no draft (a picker committing on a single gesture) applies the refresh while open.

The actor refreshes too

No live update suppresses the echo of the client's own change. The person who made the change refetches along with everyone else.

Suppressing it looks like a free saving and is not:

  • The server's answer can differ from what the actor expects. A status change also moves done_ratio and closed_on, rolls priority and dates up to the parent, and changes which transitions are offered next. Suppression leaves the person who just edited the record looking at the least accurate version of it.
  • The payload names a user, not a session. A suppressed feature never updates the same user's second tab or second device.
  • A per-feature exception is worse than either answer. Two live updates behaving differently is a rule nobody can hold in their head, and the reader cannot tell a deliberate choice from an oversight.

What it costs, and why that is acceptable: the actor's own action usually refreshes already, so the broadcast adds one debounced refetch on top. useLiveUpdates coalesces bursts, and the store's request guard keeps the late response from winning.

useLiveUpdates has no opt-out, so the rule cannot be bypassed one feature at a time. actorId stays in the payload for a different job: deciding not to flash a highlight at the person who caused the change.

Shipped: journal changes

issueJournalsChanged keeps the v2 activity panel current.

Piece Location
Subscription app/api/easy_graphql/subscriptions/issue_journals_changed.rb
Result type app/api/easy_graphql/subscriptions/results/issue_journals_changed_result.rb
Trigger Journal#broadcast_journals_changed
Composable app/frontend/src/issue_detail_v2/activity/composables/useJournalsLiveUpdates.ts

Its delivery rule is worth reading as an example of how much care a content-free payload still needs: it delivers when the subscriber can read the journal now, or could read it before this change. The second half is what clears a comment switched to private off the screens still showing it, those readers get the content-free event and refetch it away.

Shipped: issue attribute changes

issueAttributesChanged keeps the v2 attribute islands (base and detail attributes, the latter including the custom fields) current, where fields are edited one at a time.

Piece Location
Subscription app/api/easy_graphql/subscriptions/issue_attributes_changed.rb
Result type app/api/easy_graphql/subscriptions/results/issue_attributes_changed_result.rb
Shared scaffolding app/api/easy_graphql/subscriptions/concerns/issue_scoped.rb
Trigger Issue#broadcast_attributes_changed
Composable app/frontend/src/issue_detail_v2/shared/composables/useIssueAttributesLiveUpdates.ts

On screen, one event becomes one debounced re-read of each island's query (issueBaseAttributes, issueDetailAttributes, issueDetailCustomFields). A field with a write still pending keeps the selection the person is waiting on, the refreshed value only becomes what that field falls back to if the write is rejected, while every other field adopts the server's answer. Both option sets and the editable-field set are replaced by every refresh, whatever is pending. The panel refreshes on every event and never consults changedAttributes: what the pickers offer depends on keys outside the four attributes, tracker_id, project_id, the workflow roles derived from assignee and author, derived-date and derived-priority flags, so a key filter would miss changes that alter the offer.

One subscription, not one per attribute

Subscription surface
type Subscription {
  issueAttributesChanged(issueId: ID!): IssueAttributesChangedPayload!
}

type IssueAttributesChangedResult {
  event: String!                  # "updated"; room for "deleted"
  changedAttributes: [String!]!   # ["status_id", "assigned_to_id", "cf_12"]
  actorId: ID                     # attribution and highlight suppression; null for system writes
  journalId: ID                   # null when the write opened no journal
}

Per-attribute subscriptions were rejected:

  • graphql-ruby matches triggers on exact arguments, so per-attribute means one websocket subscription per attribute per open tab, and that many update authorizations per write.
  • A subset argument (attributes: [String!]) cannot work at all, the trigger would have to enumerate every subset clients might have subscribed with.
  • Attributes move in groups. A status change also moves done_ratio, and Issue#recalculate_attributes_for rolls priority, dates and done ratio up to the parent. Separate topics would emit four messages where one suffices.

changedAttributes is filtered per subscriber, so two subscribers on the same event can legitimately receive different key sets.

Key vocabulary

Keys are journal prop_key values verbatim, the vocabulary JournalDetail already uses and the history panel already renders. Custom fields force a dynamic string (cf_<id>), so there is no enum to be had and no second vocabulary to keep in sync.

Panel field Key Also moves when
Assignee assigned_to_id -
Status status_id Usually with done_ratio; closing cascades to children
Priority priority_id Rolls up to a parent with priority_derived?
Due date due_date Rolls up to a parent with dates_derived?
Start date start_date Reschedules following issues
Progress done_ratio Derived from status, or averaged from children
Tracker tracker_id Changes safe_attribute_names for everyone
Custom field cf_<id> -

Broadcast source

after_commit on: :update on Issue, not on Journal. It puts the whole changed set in one place, including custom fields, which a journal-based hook would have to reconstruct from details, and it does not depend on a journal having been opened, so a save that never called init_journal still broadcasts.

app/models/issue.rb
1
2
3
4
5
6
7
def broadcast_changed_attribute_keys
  columns = saved_changes.keys -
            self.class.journalized_options[:non_journalized_columns] -
            %w[id created_on updated_on lft rgt lock_version]

  columns + Array(changed_custom_fields)
end

Two existing mechanisms carry the weight:

  • journalized_options[:non_journalized_columns] is already the right denylist. It drops easy_last_updated_by_id, easy_status_updated_on, closed_on, root_id and the rest, the columns that move on nearly every save and would otherwise make the broadcast fire constantly for nothing.
  • changed_custom_fields (from acts_as_customizable) is an attr_reader set in save_custom_field_values and cleared only on reload, so it is still readable in after_commit, and it yields ["cf_12"] directly. saved_changes alone misses custom fields entirely, because they live in their own records.

System and derived writes broadcast too. Automations and recalculate_attributes_for go through init_system_journal, and a parent's panel should update from them.

Known gaps

after_commit does not run for writes that bypass callbacks, so these leave a panel stale until the user refreshes:

Path What it changes Notes
Issue#close_children status_id, done_ratio on descendants via update_columns The only such path inside Issue. It writes a journal by hand right after, so issueJournalsChanged does fire for the descendant - only the attribute broadcast is missing. Left out deliberately: close_children runs from before_save on the parent, so a trigger written next to the hand-written journal would announce a change that has not committed and can still roll back. Closing it properly means recording the descendants during close_children and announcing them from the parent's after_commit. Worth doing if an open subtask's stale panel proves to matter.
IssuePriority#destroy, IssueStatus, Tracker, IssueCategory reassignment priority_id, status_id, tracker_id, category_id via update_all Administrative, low frequency. Not worth a trigger unless it proves otherwise.
User/Group destroy assigned_to_id nulled via update_all Same.

None of these are regressions, they are simply outside the hook's reach, and each can be given an explicit trigger later if it matters.

Filtering keys per subscriber

Filtering the key list rather than values keeps the permission surface small and directly testable, and a subscriber who cannot see estimated_hours is not woken when only that changed.

app/api/easy_graphql/subscriptions/issue_attributes_changed.rb
# Mirrors Journal#visible_details: journalized_attribute_names carries the estimated_hours
# permission and the tracker's disabled core fields; custom fields carry their own.
def visible_changed_attributes(issue_id)
  attr_keys, cf_keys = Array(payload[:changed_attributes]).partition { |key| !key.start_with?("cf_") }

  allowed = attribute_names_for_subscriber(issue_id)
  attr_keys.select! { |key| allowed.include?(key) }
  cf_keys.select! { |key| custom_field_visible?(key) }

  attr_keys + cf_keys
end

update then answers NO_UPDATE when that set comes back empty, so an event whose every key is hidden from this subscriber is not an event as far as they are concerned.

Issue#journalized_attribute_names reads User.current, while subscriber is a separately reloaded object, so attribute_names_for_subscriber wraps the call in User#execute, the alternative being to take the user explicitly the way Journal#visible_details(user) does. Getting this wrong is silent: it filters against a stale permission set rather than raising.

Overlap with journals

Every journaled attribute change fires both subscriptions. That is intended: they answer different questions and feed different stores, and they debounce independently.

flowchart TD
  A[Someone changes the status] --> B[Issue saves, journal created in the same transaction]
  B --> C[issue_journals_changed from Journal]
  B --> D[issue_attributes_changed from Issue]
  C --> E[Authorized against the private-notes rule]
  D --> F[Authorized against the visible key set]
  E --> G[Activity panel refetches journals]
  F --> H[Attributes panel refetches attributes]

Reusing issueJournalsChanged for attributes was rejected: its gate is the private-notes rule, which is the wrong question for an attribute, and it would make the attributes panel refetch on every comment posted on a busy issue.

Adding a new live attribute

Attributes backed by issues columns and custom fields are covered by the after_commit hook and need nothing. Association-backed ones do not appear in Issue#saved_changes and need their own trigger point calling the same method:

Attribute Key Trigger point
Watchers watcher_ids Watcher after_commit, scoped to watchable_type == "Issue"
Tags tag_list Tagging callback
Relations relations IssueRelation after_commit, both ends
Spent time spent_hours TimeEntry after_commit, plus ancestors for total_spent_hours
Attachments attachments Already journaled; needs an explicit trigger for the panel

Each costs one line in a callback, one row in the vocabulary table above, and one case in the frontend map. No GraphQL change, no new subscription, no new websocket topic.

Ancestor fan-out deserves care when it arrives: a time entry on a deep subtask should wake every ancestor's panel, which is one trigger per ancestor. Batch it if it shows up in profiling.