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.
useCommentsStorekeeps an increasingrequestIdand 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_ratioandclosed_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¶
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
updateauthorizations 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, andIssue#recalculate_attributes_forrolls 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 | |
|---|---|
Two existing mechanisms carry the weight:
journalized_options[:non_journalized_columns]is already the right denylist. It dropseasy_last_updated_by_id,easy_status_updated_on,closed_on,root_idand the rest, the columns that move on nearly every save and would otherwise make the broadcast fire constantly for nothing.changed_custom_fields(fromacts_as_customizable) is anattr_readerset insave_custom_field_valuesand cleared only onreload, so it is still readable inafter_commit, and it yields["cf_12"]directly.saved_changesalone 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.
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.