Merge changes I8d678fa7,I882fd504

* changes:
  Make a viewState a state in gr-editor-view
  Fix “Old Patchset” being displayed on current edits
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 7bbee2a..ff811a0 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -242,7 +242,9 @@
   }
 
   private int getInsertionsCount() {
-    return listModifiedFiles().values().stream()
+    return listModifiedFiles().entrySet().stream()
+        .filter(e -> !Patch.COMMIT_MSG.equals(e.getKey()))
+        .map(Map.Entry::getValue)
         .map(FileDiffOutput::insertions)
         .reduce(0, Integer::sum);
   }
@@ -323,8 +325,8 @@
                     + "{1,choice,0#0 insertions|1#1 insertion|1<{1} insertions}(+), " //
                     + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
                     + "\n",
-                modifiedFiles.size() - 1, //
-                getInsertionsCount(), //
+                modifiedFiles.size() - 1, // -1 to account for the commit message
+                getInsertionsCount(),
                 getDeletionsCount()));
         detail.append("\n");
       }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index e3d69e1..21fc4b4 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -101,6 +101,7 @@
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
@@ -196,6 +197,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
@@ -4551,6 +4553,47 @@
         .contains(String.format("%s has removed %s", admin.fullName(), reviewerInput.reviewer));
   }
 
+  @Test
+  public void emailSubjectContainsChangeSizeBucket() throws Exception {
+    testEmailSubjectContainsChangeSizeBucket(0, "NoOp");
+    testEmailSubjectContainsChangeSizeBucket(1, "XS");
+    testEmailSubjectContainsChangeSizeBucket(9, "XS");
+    testEmailSubjectContainsChangeSizeBucket(10, "S");
+    testEmailSubjectContainsChangeSizeBucket(49, "S");
+    testEmailSubjectContainsChangeSizeBucket(50, "M");
+    testEmailSubjectContainsChangeSizeBucket(249, "M");
+    testEmailSubjectContainsChangeSizeBucket(250, "L");
+    testEmailSubjectContainsChangeSizeBucket(999, "L");
+    testEmailSubjectContainsChangeSizeBucket(1000, "XL");
+  }
+
+  private void testEmailSubjectContainsChangeSizeBucket(
+      int numberOfLines, String expectedSizeBucket) throws Exception {
+    String change;
+    if (numberOfLines == 0) {
+      // create empty change
+      ChangeInput in = new ChangeInput();
+      in.branch = Constants.MASTER;
+      in.subject = "Create a change from the API";
+      in.project = project.get();
+      ChangeInfo info = gApi.changes().create(in).get();
+      change = info.changeId;
+    } else {
+      change =
+          createChange(
+                  "subject",
+                  expectedSizeBucket + "-file-with-" + numberOfLines + "lines.txt",
+                  Collections.nCopies(numberOfLines, "line").stream().collect(joining("\n")))
+              .getChangeId();
+    }
+    sender.clear();
+    gApi.changes().id(change).addReviewer(user.email());
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    assertThat(((StringEmailHeader) messages.get(0).headers().get("Subject")).getString())
+        .contains("[" + expectedSizeBucket + "]");
+  }
+
   private PushOneCommit.Result createWorkInProgressChange() throws Exception {
     return pushTo("refs/for/master%wip");
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index e44bfcf..cced47f 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -984,7 +984,7 @@
     StagedPreChange spc = stagePreChange("refs/for/master");
     assertThat(sender)
         .sent("newchange", spc)
-        .title(String.format("[S] Change in %s[master]: test commit", project));
+        .title(String.format("[XS] Change in %s[master]: test commit", project));
     assertThat(sender).didNotSend();
   }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
index 42ec988..bb59c0c 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
@@ -7,6 +7,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css, html, LitElement} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
+import {fireEventNoBubble} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -68,22 +69,12 @@
   _handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEventNoBubble(this, 'confirm');
   }
 
   _handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEventNoBubble(this, 'cancel');
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 8e36aa8..1ec83efa8 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -13,7 +13,7 @@
   AutocompleteQuery,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
-import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {fire, firePageError, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {convertToString} from '../../../utils/string-util';
@@ -48,16 +48,13 @@
   interface HTMLElementTagNameMap {
     'gr-group': GrGroup;
   }
+  interface HTMLElementEventMap {
+    'name-changed': CustomEvent<GroupNameChangedDetail>;
+  }
 }
 
 @customElement('gr-group')
 export class GrGroup extends LitElement {
-  /**
-   * Fired when the group name changes.
-   *
-   * @event name-changed
-   */
-
   private readonly query: AutocompleteQuery;
 
   @property({type: String})
@@ -373,13 +370,7 @@
         name: groupName,
         external: !this.groupIsInternal,
       };
-      this.dispatchEvent(
-        new CustomEvent('name-changed', {
-          detail,
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, 'name-changed', detail);
       this.requestUpdate();
     }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
index 54a83ee..5afaa9d 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
@@ -15,21 +15,19 @@
 import {LitElement, html, css} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-plugin-config-array-editor': GrPluginConfigArrayEditor;
   }
+  interface HTMLElementEventMap {
+    'plugin-config-option-changed': CustomEvent<PluginConfigOptionsChangedEventDetail>;
+  }
 }
 
 @customElement('gr-plugin-config-array-editor')
 export class GrPluginConfigArrayEditor extends LitElement {
-  /**
-   * Fired when the plugin config option changes.
-   *
-   * @event plugin-config-option-changed
-   */
-
   // private but used in test
   @state() newValue = '';
 
@@ -175,9 +173,7 @@
       info: {...info, values},
       notifyPath: `${_key}.values`,
     };
-    this.dispatchEvent(
-      new CustomEvent('plugin-config-option-changed', {detail})
-    );
+    fireNoBubbleNoCompose(this, 'plugin-config-option-changed', detail);
   }
 
   private handleBindValueChangedNewValue(e: BindValueChangeEvent) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
index f8dcd32..2772519 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
@@ -25,8 +25,7 @@
   PluginOption,
 } from './gr-repo-plugin-config-types';
 import {paperStyles} from '../../../styles/gr-paper-styles';
-
-const PLUGIN_CONFIG_CHANGED_EVENT_NAME = 'plugin-config-changed';
+import {fire} from '../../../utils/event-util';
 
 export interface ConfigChangeInfo {
   _key: string; // parameterName of PluginParameterToConfigParameterInfoMap
@@ -255,14 +254,7 @@
       name,
       config: {...config, [_key]: info},
     };
-
-    this.dispatchEvent(
-      new CustomEvent(PLUGIN_CONFIG_CHANGED_EVENT_NAME, {
-        detail,
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fire(this, 'plugin-config-changed', detail);
   }
 
   /**
@@ -277,4 +269,7 @@
   interface HTMLElementTagNameMap {
     'gr-repo-plugin-config': GrRepoPluginConfig;
   }
+  interface HTMLElementEventMap {
+    'plugin-config-changed': CustomEvent<PluginConfigChangeDetail>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index 5f0a171..82a4eb5 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -8,12 +8,12 @@
 import '../../shared/gr-select/gr-select';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {AccessPermissionId} from '../../../utils/access-util';
-import {fireEvent} from '../../../utils/event-util';
+import {fire, fireEvent} from '../../../utils/event-util';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
-import {BindValueChangeEvent} from '../../../types/events';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {EditablePermissionRuleInfo} from '../gr-repo-access/gr-repo-access-interfaces';
 import {PermissionAction} from '../../../constants/constants';
@@ -81,6 +81,9 @@
   interface HTMLElementTagNameMap {
     'gr-rule-editor': GrRuleEditor;
   }
+  interface HTMLElementEventMap {
+    'rule-changed': ValueChangedEvent<Rule | undefined>;
+  }
 }
 
 @customElement('gr-rule-editor')
@@ -537,13 +540,6 @@
 
   private handleRuleChange() {
     this.requestUpdate('rule');
-
-    this.dispatchEvent(
-      new CustomEvent('rule-changed', {
-        detail: {value: this.rule},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'rule-changed', {value: this.rule});
   }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
index 561fffd..90fff4d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
@@ -12,6 +12,7 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
 import {modalStyles} from '../../../styles/gr-modal-styles';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 export interface CreateDestinationConfirmDetail {
   repo?: RepoName;
@@ -88,7 +89,7 @@
     // 'confirm' event here, so let's stop propagation of the bare event.
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('confirm', {detail, bubbles: false}));
+    fireNoBubbleNoCompose(this, 'confirm', detail);
   };
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index e47b450..259c8be 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -76,7 +76,9 @@
 import {
   fire,
   fireAlert,
+  fireError,
   fireEvent,
+  fireEventNoBubbleNoCompose,
   fireReload,
 } from '../../../utils/event-util';
 import {
@@ -84,7 +86,7 @@
   getVotingRange,
   StandardLabels,
 } from '../../../utils/label-util';
-import {EventType, ShowAlertEventDetail} from '../../../types/events';
+import {EventType} from '../../../types/events';
 import {
   ActionPriority,
   ActionType,
@@ -334,18 +336,6 @@
    * @event custom-tap - naming pattern: <action key>-tap
    */
 
-  /**
-   * Fires to show an alert when a send is attempted on the non-latest patch.
-   *
-   * @event show-alert
-   */
-
-  /**
-   * Fires when a change action fails.
-   *
-   * @event show-error
-   */
-
   @query('#mainContent') mainContent?: Element;
 
   @query('#actionsModal') actionsModal?: HTMLDialogElement;
@@ -1912,13 +1902,7 @@
   ) {
     if (!response) {
       return Promise.resolve(() => {
-        this.dispatchEvent(
-          new CustomEvent('show-error', {
-            detail: {message: `Could not perform action '${action.__key}'`},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireError(this, `Could not perform action '${action.__key}'`);
       });
     }
     if (action && action.__key === RevisionActions.CHERRYPICK) {
@@ -1936,13 +1920,7 @@
       }
     }
     return response.text().then(errText => {
-      this.dispatchEvent(
-        new CustomEvent('show-error', {
-          detail: {message: `Could not perform action: ${errText}`},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireError(this, `Could not perform action: ${errText}`);
       if (!errText.startsWith('Change is already up to date')) {
         throw Error(errText);
       }
@@ -1973,19 +1951,13 @@
       .fetchChangeUpdates(change)
       .then(result => {
         if (!result.isLatest) {
-          this.dispatchEvent(
-            new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
-              detail: {
-                message:
-                  'Cannot set label: a newer patch has been ' +
-                  'uploaded to this change.',
-                action: 'Reload',
-                callback: () => fireReload(this, true),
-              },
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fire(this, EventType.SHOW_ALERT, {
+            message:
+              'Cannot set label: a newer patch has been ' +
+              'uploaded to this change.',
+            action: 'Reload',
+            callback: () => fireReload(this, true),
+          });
 
           // Because this is not a network error, call the cleanup function
           // but not the error handler.
@@ -2241,11 +2213,11 @@
   }
 
   private handleEditTap() {
-    this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
+    fireEventNoBubbleNoCompose(this, 'edit-tap');
   }
 
   private handleStopEditTap() {
-    this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
+    fireEventNoBubbleNoCompose(this, 'stop-edit-tap');
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 4c7c857..3afe055 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -119,7 +119,7 @@
   EventType,
   FileActionTapEvent,
   OpenFixPreviewEvent,
-  ShowAlertEventDetail,
+  ShowReplyDialogEvent,
   SwitchTabEvent,
   TabState,
   ValueChangedEvent,
@@ -128,6 +128,7 @@
 import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
 import {
+  fire,
   fireAlert,
   fireDialogChange,
   fireEvent,
@@ -2009,7 +2010,7 @@
   }
 
   // Private but used in tests.
-  handleShowReplyDialog(e: CustomEvent<{value: {ccsOnly: boolean}}>) {
+  handleShowReplyDialog(e: ShowReplyDialogEvent) {
     let target = FocusTarget.REVIEWERS;
     if (e.detail.value && e.detail.value.ccsOnly) {
       target = FocusTarget.CCS;
@@ -3055,20 +3056,14 @@
           }
 
           this.cancelUpdateCheckTimer();
-          this.dispatchEvent(
-            new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
-              detail: {
-                message: toastMessage,
-                // Persist this alert.
-                dismissOnNavigation: true,
-                showDismiss: true,
-                action: 'Reload',
-                callback: () => fireReload(this, true),
-              },
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fire(this, EventType.SHOW_ALERT, {
+            message: toastMessage,
+            // Persist this alert.
+            dismissOnNavigation: true,
+            showDismiss: true,
+            action: 'Reload',
+            callback: () => fireReload(this, true),
+          });
         });
     }, this.serverConfig.change.update_delay * 1000);
   }
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index ac4c1f9..34d11a3 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -1772,7 +1772,7 @@
     const openStub = sinon.stub(element, 'openReplyDialog');
 
     const e = new CustomEvent('show-reply-dialog', {
-      detail: {value: {ccsOnly: false}},
+      detail: {value: {reviewersOnly: true, ccsOnly: false}},
     });
     element.handleShowReplyDialog(e);
     assert(
@@ -1781,7 +1781,7 @@
     );
     assert.equal(openStub.callCount, 1);
 
-    e.detail.value = {ccsOnly: true};
+    e.detail.value = {reviewersOnly: false, ccsOnly: true};
     element.handleShowReplyDialog(e);
     assert(
       openStub.lastCall.calledWithExactly(FocusTarget.CCS),
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
index 85746df..cbe3430 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
@@ -14,6 +14,7 @@
 import {BindValueChangeEvent} from '../../../types/events';
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {ChangeActionDialog} from '../../../types/common';
+import {fireEventNoBubble, fireNoBubble} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -132,25 +133,14 @@
 
   // private but used in test
   confirm() {
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        detail: {reason: this.message},
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'confirm', {reason: this.message});
   }
 
   // private but used in test
   handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEventNoBubble(this, 'cancel');
   }
 
   private handleBindValueChanged(e: BindValueChangeEvent) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
index 02156df..34a3161 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
@@ -7,6 +7,7 @@
 import {customElement} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ChangeActionDialog} from '../../../types/common';
+import {fireEventNoBubble} from '../../../utils/event-util';
 import '../../shared/gr-dialog/gr-dialog';
 
 @customElement('gr-confirm-cherrypick-conflict-dialog')
@@ -66,23 +67,13 @@
   handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEventNoBubble(this, 'confirm');
   }
 
   handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEventNoBubble(this, 'cancel');
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index 5f3b824..8ce0f51 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -30,7 +30,7 @@
   ChangeStatus,
   ProgressStatus,
 } from '../../../constants/constants';
-import {fireEvent} from '../../../utils/event-util';
+import {fireEvent, fireEventNoBubble} from '../../../utils/event-util';
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {choose} from 'lit/directives/choose.js';
@@ -605,23 +605,13 @@
       return;
     }
     // Cherry pick single change
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEventNoBubble(this, 'confirm');
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEventNoBubble(this, 'cancel');
   }
 
   resetFocus() {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
index 3f84189..db14694 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
@@ -15,6 +15,7 @@
 import {ValueChangedEvent} from '../../../types/events';
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {fireEventNoBubble} from '../../../utils/event-util';
 
 const SUGGESTIONS_LIMIT = 15;
 
@@ -142,23 +143,13 @@
   private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEventNoBubble(this, 'confirm');
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEventNoBubble(this, 'cancel');
   }
 
   private getProjectBranchesSuggestions(input: string) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index ad2ba8f..3150fc8 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -22,6 +22,10 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ValueChangedEvent} from '../../../types/events';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {
+  fireEventNoBubbleNoCompose,
+  fireNoBubbleNoCompose,
+} from '../../../utils/event-util';
 
 export interface RebaseChange {
   name: string;
@@ -351,14 +355,14 @@
       allowConflicts: this.rebaseAllowConflicts.checked,
       rebaseChain: !!this.rebaseChain?.checked,
     };
-    this.dispatchEvent(new CustomEvent('confirm', {detail}));
+    fireNoBubbleNoCompose(this, 'confirm', detail);
     this.text = '';
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('cancel'));
+    fireEventNoBubbleNoCompose(this, 'cancel');
     this.text = '';
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index 1b5f171..bf1c5ca 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -21,6 +21,7 @@
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {resolve} from '../../../models/dependency';
+import {fireEventNoBubbleNoCompose} from '../../../utils/event-util';
 
 @customElement('gr-confirm-submit-dialog')
 export class GrConfirmSubmitDialog
@@ -193,13 +194,13 @@
   private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
+    fireEventNoBubbleNoCompose(this, 'confirm');
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
+    fireEventNoBubbleNoCompose(this, 'cancel');
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 5744b02..4403a8f 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -25,7 +25,7 @@
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {fireEvent} from '../../../utils/event-util';
+import {fireEvent, fireEventNoBubbleNoCompose} from '../../../utils/event-util';
 import {css, html, LitElement} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {when} from 'lit/directives/when.js';
@@ -425,9 +425,7 @@
   private handleDownloadTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('open-download-dialog', {bubbles: false})
-    );
+    fireEventNoBubbleNoCompose(this, 'open-download-dialog');
   }
 
   private computeEditModeClass(editMode?: boolean) {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index b9850dd..ad75253 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -174,11 +174,6 @@
 }
 @customElement('gr-file-list')
 export class GrFileList extends LitElement {
-  /**
-   * @event files-expanded-changed
-   * @event files-shown-changed
-   * @event diff-prefs-changed
-   */
   @query('#diffPreferencesDialog')
   diffPreferencesDialog?: GrDiffPreferencesDialog;
 
@@ -2273,13 +2268,7 @@
     const previousNumFilesShown = this.shownFiles ? this.shownFiles.length : 0;
 
     const filesShown = this.files.slice(0, this.numFilesShown);
-    this.dispatchEvent(
-      new CustomEvent('files-shown-changed', {
-        detail: {length: filesShown.length},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'files-shown-changed', {length: filesShown.length});
 
     // Start the timer for the rendering work here because this is where the
     // shownFiles property is being set, and shownFiles is used in the
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
index fcfe209..c6eaef7 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -12,6 +12,7 @@
 import {LitElement, PropertyValues, html, css} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
+import {fireEventNoBubble} from '../../../utils/event-util';
 
 interface DisplayGroup {
   title: string;
@@ -197,12 +198,7 @@
   private handleCloseTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('close', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEventNoBubble(this, 'close');
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
index 50c5caf..c669209 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
@@ -18,21 +18,20 @@
 import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
 import {Label} from '../../../utils/label-util';
 import {LabelNameToValuesMap} from '../../../api/rest-api';
+import {fire} from '../../../utils/event-util';
+import {LabelsChangedDetail} from '../../../api/change-reply';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-label-score-row': GrLabelScoreRow;
   }
+  interface HTMLElementEventMap {
+    'labels-changed': CustomEvent<LabelsChangedDetail>;
+  }
 }
 
 @customElement('gr-label-score-row')
 export class GrLabelScoreRow extends LitElement {
-  /**
-   * Fired when any label is changed.
-   *
-   * @event labels-changed
-   */
-
   @query('#labelSelector')
   labelSelector?: IronSelectorElement;
 
@@ -365,13 +364,7 @@
     this.selectedValueText = selectedItem.getAttribute('title') || '';
     const name = selectedItem.dataset['name'];
     const value = selectedItem.dataset['value'];
-    this.dispatchEvent(
-      new CustomEvent('labels-changed', {
-        detail: {name, value},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    if (name && value) fire(this, 'labels-changed', {name, value});
   };
 
   private computePermittedLabelValues() {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index a4da747..992a1dc 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -48,6 +48,11 @@
 import {FormattedReviewerUpdateInfo} from '../../../types/types';
 import {resolve} from '../../../models/dependency';
 import {createChangeUrl} from '../../../models/views/change';
+import {fire} from '../../../utils/event-util';
+import {
+  ChangeMessageDeletedEventDetail,
+  ReplyEvent,
+} from '../../../types/events';
 
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
@@ -55,6 +60,12 @@
   interface HTMLElementTagNameMap {
     'gr-message': GrMessage;
   }
+  interface HTMLElementEventMap {
+    'message-anchor-tap': CustomEvent<MessageAnchorTapDetail>;
+    'change-message-deleted': CustomEvent<ChangeMessageDeletedEventDetail>;
+    /* prettier-ignore */
+    'reply': ReplyEvent;
+  }
 }
 
 export interface MessageAnchorTapDetail {
@@ -70,12 +81,6 @@
    */
 
   /**
-   * Fired when the message's timestamp is tapped.
-   *
-   * @event message-anchor-tap
-   */
-
-  /**
    * Fired when a change message is deleted.
    *
    * @event change-message-deleted
@@ -751,24 +756,13 @@
     const detail: MessageAnchorTapDetail = {
       id: this.message!.id,
     };
-    this.dispatchEvent(
-      new CustomEvent('message-anchor-tap', {
-        bubbles: true,
-        composed: true,
-        detail,
-      })
-    );
+    fire(this, 'message-anchor-tap', detail);
   }
 
   private handleReplyTap(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('reply', {
-        detail: {message: this.message},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    // TODO: Fix the type casting. Might actually be a bug.
+    fire(this, 'reply', {message: this.message as ChangeMessage});
   }
 
   private handleDeleteMessage(e: Event) {
@@ -779,13 +773,10 @@
       .deleteChangeCommitMessage(this.changeNum, this.message.id)
       .then(() => {
         this.isDeletingChangeMsg = false;
-        this.dispatchEvent(
-          new CustomEvent('change-message-deleted', {
-            detail: {message: this.message},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        // TODO: Fix the type casting. Might actually be a bug.
+        fire(this, 'change-message-deleted', {
+          message: this.message as ChangeMessage,
+        });
       });
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index da49af0..9f2c6be 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -92,7 +92,10 @@
 import {pluralize} from '../../../utils/string-util';
 import {
   fireAlert,
+  fireError,
   fireEvent,
+  fireEventNoBubble,
+  fireEventNoBubbleNoCompose,
   fireIronAnnounce,
   fireReload,
   fireServerError,
@@ -1482,12 +1485,7 @@
 
         this.patchsetLevelDraftMessage = '';
         this.includeComments = true;
-        this.dispatchEvent(
-          new CustomEvent('send', {
-            composed: true,
-            bubbles: false,
-          })
-        );
+        fireEventNoBubble(this, 'send');
         fireIronAnnounce(this, 'Reply sent');
         return;
       })
@@ -1870,12 +1868,7 @@
   async cancel() {
     assertIsDefined(this.change, 'change');
     if (!this.change?.owner) throw new Error('missing required owner property');
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEventNoBubble(this, 'cancel');
     await this.patchsetLevelGrComment?.save();
     this.rebuildReviewerArrays();
   }
@@ -1906,13 +1899,7 @@
       return;
     }
     return this.send(this.includeComments, this.canBeStarted).catch(err => {
-      this.dispatchEvent(
-        new CustomEvent('show-error', {
-          bubbles: true,
-          composed: true,
-          detail: {message: `Error submitting review ${err}`},
-        })
-      );
+      fireError(this, `Error submitting review ${err}`);
     });
   }
 
@@ -2089,7 +2076,7 @@
   }
 
   sendDisabledChanged() {
-    this.dispatchEvent(new CustomEvent('send-disabled-changed'));
+    fireEventNoBubbleNoCompose(this, 'send-disabled-changed');
   }
 
   getReviewerSuggestionsProvider(change?: ChangeInfo | ParsedChangeInfo) {
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 9408b82..db19329 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -23,15 +23,11 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css} from 'lit';
 import {nothing} from 'lit';
+import {fire} from '../../../utils/event-util';
+import {ShowReplyDialogEvent} from '../../../types/events';
 
 @customElement('gr-reviewer-list')
 export class GrReviewerList extends LitElement {
-  /**
-   * Fired when the "Add reviewer..." button is tapped.
-   *
-   * @event show-reply-dialog
-   */
-
   @property({type: Object}) change?: ChangeInfo;
 
   @property({type: Object}) account?: AccountDetailInfo;
@@ -203,22 +199,10 @@
   handleAddTap(e: Event) {
     e.preventDefault();
     const value = {
-      reviewersOnly: false,
-      ccsOnly: false,
+      reviewersOnly: this.reviewersOnly,
+      ccsOnly: this.ccsOnly,
     };
-    if (this.reviewersOnly) {
-      value.reviewersOnly = true;
-    }
-    if (this.ccsOnly) {
-      value.ccsOnly = true;
-    }
-    this.dispatchEvent(
-      new CustomEvent('show-reply-dialog', {
-        detail: {value},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'show-reply-dialog', {value});
   }
 }
 
@@ -226,4 +210,8 @@
   interface HTMLElementTagNameMap {
     'gr-reviewer-list': GrReviewerList;
   }
+  interface HTMLElementEventMap {
+    /** Fired when the "Add reviewer..." button is tapped. */
+    'show-reply-dialog': ShowReplyDialogEvent;
+  }
 }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 128a9b0a..8ba9895 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -634,7 +634,7 @@
     return Object.entries(this.errorMessages).map(([plugin, message]) => {
       const msg = this.collapsed
         ? 'Error'
-        : `Error while fetching results for ${plugin}:<br />${message}`;
+        : html`Error while fetching results for ${plugin}:<br />${message}`;
       return html`
         <div class="error">
           <div class="left">
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
index 4bd3446..a858e4d 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
@@ -22,6 +22,7 @@
     );
     const getChecksModel = resolve(element, checksModelToken);
     setAllFakeRuns(getChecksModel());
+    element.errorMessages = {'test-plugin-name': 'test-error-message'};
     await element.updateComplete;
   });
 
@@ -57,6 +58,17 @@
             </gr-button>
           </gr-tooltip-content>
         </h2>
+        <div class="error">
+          <div class="left">
+            <gr-icon filled="" icon="error"> </gr-icon>
+          </div>
+          <div class="right">
+            <div class="message">
+              Error while fetching results for test-plugin-name: <br />
+              test-error-message
+            </div>
+          </div>
+        </div>
         <input
           id="filterInput"
           placeholder="Filter runs by regular expression"
@@ -121,6 +133,14 @@
             </gr-button>
           </gr-tooltip-content>
         </h2>
+        <div class="error">
+          <div class="left">
+            <gr-icon filled="" icon="error"> </gr-icon>
+          </div>
+          <div class="right">
+            <div class="message">Error</div>
+          </div>
+        </div>
         <input
           hidden
           id="filterInput"
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-util.ts b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
index c7477c4..f1a3fb9 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-util.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
@@ -9,6 +9,7 @@
   AttemptChoice,
   LATEST_ATTEMPT,
 } from '../../models/checks/checks-util';
+import {fire} from '../../utils/event-util';
 
 export interface RunSelectedEventDetail {
   checkName?: string;
@@ -23,13 +24,7 @@
 }
 
 export function fireRunSelected(target: EventTarget, checkName: string) {
-  target.dispatchEvent(
-    new CustomEvent('run-selected', {
-      detail: {reset: false, checkName},
-      composed: true,
-      bubbles: true,
-    })
-  );
+  fire(target, 'run-selected', {checkName});
 }
 
 export function isAttemptSelected(
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
index 461781e..3898186 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
@@ -7,6 +7,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, html, css} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
+import {fireEventNoBubbleNoCompose} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -83,6 +84,6 @@
   }
 
   private handleConfirm() {
-    this.dispatchEvent(new CustomEvent('dismiss'));
+    fireEventNoBubbleNoCompose(this, 'dismiss');
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
index 45ba33b..68ff9e1 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
@@ -16,6 +16,7 @@
   ShortcutViewListener,
 } from '../../../services/shortcuts/shortcuts-service';
 import {resolve} from '../../../models/dependency';
+import {fireEventNoBubble} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -162,12 +163,7 @@
   private handleCloseTap(e: MouseEvent) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('close', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEventNoBubble(this, 'close');
   }
 
   onDirectoryUpdated(directory?: Map<ShortcutSection, SectionView>) {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index fe90471..1b377e8 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -26,7 +26,7 @@
 import {AppElement, AppElementParams} from '../../gr-app-types';
 import {LocationChangeEventDetail} from '../../../types/events';
 import {GerritView, RouterModel} from '../../../services/router/router-model';
-import {fireAlert, firePageError} from '../../../utils/event-util';
+import {fire, fireAlert, firePageError} from '../../../utils/event-util';
 import {windowLocationReload} from '../../../utils/dom-util';
 import {
   encodeURL,
@@ -555,13 +555,7 @@
       hash: window.location.hash,
       pathname: window.location.pathname,
     };
-    document.dispatchEvent(
-      new CustomEvent('location-change', {
-        detail,
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(document, 'location-change', detail);
   }
 
   _testOnly_startRouter() {
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index 9dbb01c..406755f 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -27,6 +27,7 @@
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
 import {ValueChangedEvent} from '../../../types/events';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 // Possible static search options for auto complete, without negations.
 const SEARCH_OPERATORS: ReadonlyArray<string> = [
@@ -311,11 +312,7 @@
       const detail: SearchBarHandleSearchDetail = {
         inputVal: this.inputVal,
       };
-      this.dispatchEvent(
-        new CustomEvent('handle-search', {
-          detail,
-        })
-      );
+      fireNoBubbleNoCompose(this, 'handle-search', detail);
     }
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
index 531d2ae..69ff201 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
@@ -12,6 +12,7 @@
 import {customElement, query, state} from 'lit/decorators.js';
 import {ValueChangedEvent} from '../../../types/events';
 import {modalStyles} from '../../../styles/gr-modal-styles';
+import {fireEventNoBubble} from '../../../utils/event-util';
 
 @customElement('gr-diff-preferences-dialog')
 export class GrDiffPreferencesDialog extends LitElement {
@@ -120,12 +121,7 @@
     assertIsDefined(this.diffPreferences, 'diffPreferences');
     assertIsDefined(this.diffPrefsModal, 'diffPrefsModal');
     await this.diffPreferences.save();
-    this.dispatchEvent(
-      new CustomEvent('reload-diff-preference', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEventNoBubble(this, 'reload-diff-preference');
     this.diffPrefsModal.close();
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index 58ad721..01c007b 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -47,6 +47,7 @@
 import {GeneratedWebLink} from '../../../utils/weblink-util';
 import {changeModelToken} from '../../../models/change/change-model';
 import {changeViewModelToken} from '../../../models/views/change';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -496,8 +497,6 @@
       detail.basePatchNum = patchSetValue as BasePatchSetNum;
     }
 
-    this.dispatchEvent(
-      new CustomEvent('patch-range-change', {detail, bubbles: false})
-    );
+    fireNoBubbleNoCompose(this, 'patch-range-change', detail);
   }
 }
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
index 7229c63..25d3cf4 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
@@ -6,11 +6,16 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
+import {fire} from '../../../utils/event-util';
+import {ValueChangedEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-default-editor': GrDefaultEditor;
   }
+  interface HTMLElementEventMap {
+    'content-change': ValueChangedEvent;
+  }
 }
 
 @customElement('gr-default-editor')
@@ -56,12 +61,7 @@
   }
 
   _handleTextareaInput(e: Event) {
-    this.dispatchEvent(
-      new CustomEvent('content-change', {
-        detail: {value: (e.target as HTMLTextAreaElement).value},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    const value = (e.target as HTMLTextAreaElement).value;
+    fire(this, 'content-change', {value});
   }
 }
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
index 594d9d9..751b64d 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
@@ -6,10 +6,11 @@
 import '../../shared/gr-dropdown/gr-dropdown';
 import {GrEditConstants} from '../gr-edit-constants';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {FileActionTapEventDetail} from '../../../types/events';
+import {FileActionTapEvent} from '../../../types/events';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
+import {fire} from '../../../utils/event-util';
 
 interface EditAction {
   label: string;
@@ -18,12 +19,6 @@
 
 @customElement('gr-edit-file-controls')
 export class GrEditFileControls extends LitElement {
-  /**
-   * Fired when an action in the overflow menu is tapped.
-   *
-   * @event file-action-tap
-   */
-
   @property({type: String})
   filePath?: string;
 
@@ -76,13 +71,7 @@
   }
 
   _dispatchFileAction(action: string, path: string) {
-    this.dispatchEvent(
-      new CustomEvent<FileActionTapEventDetail>('file-action-tap', {
-        detail: {action, path},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fire(this, 'file-action-tap', {action, path});
   }
 
   _computeFileActions(actions: EditAction[]): DropdownLink[] {
@@ -100,4 +89,7 @@
   interface HTMLElementTagNameMap {
     'gr-edit-file-controls': GrEditFileControls;
   }
+  interface HTMLElementEventMap {
+    'file-action-tap': FileActionTapEvent;
+  }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
index e73aad6..d3429fe 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
@@ -5,6 +5,7 @@
  */
 import {LitElement, PropertyValues} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -22,9 +23,7 @@
 
   override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('value')) {
-      this.dispatchEvent(
-        new CustomEvent('value-changed', {detail: {value: this.value}})
-      );
+      fireNoBubbleNoCompose(this, 'value-changed', {value: this.value});
     }
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index 31e4f5d..e8c5c92 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -17,6 +17,8 @@
 import {customElement, property} from 'lit/decorators.js';
 import {ClassInfo, classMap} from 'lit/directives/class-map.js';
 import {getLabelStatus, hasVoted, LabelStatus} from '../../../utils/label-util';
+import {fire} from '../../../utils/event-util';
+import {RemoveAccountEvent} from '../../../types/events';
 
 @customElement('gr-account-chip')
 export class GrAccountChip extends LitElement {
@@ -196,13 +198,8 @@
 
   private handleRemoveTap(e: MouseEvent) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('remove', {
-        detail: {account: this.account},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    if (!this.account) return;
+    fire(this, 'remove', {account: this.account});
   }
 
   private getHasAvatars() {
@@ -232,4 +229,8 @@
   interface HTMLElementTagNameMap {
     'gr-account-chip': GrAccountChip;
   }
+  interface HTMLElementEventMap {
+    /* prettier-ignore */
+    'remove': RemoveAccountEvent;
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
index 08f7c93..f07bee6 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
@@ -12,9 +12,10 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
-import {BindValueChangeEvent} from '../../../types/events';
+import {AddAccountEvent, BindValueChangeEvent} from '../../../types/events';
 import {SuggestedReviewerInfo} from '../../../types/common';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {fire, fireEvent} from '../../../utils/event-util';
 
 /**
  * gr-account-entry is an element for entering account
@@ -24,20 +25,6 @@
 export class GrAccountEntry extends LitElement {
   @query('#input') private input?: GrAutocomplete;
 
-  /**
-   * Fired when an account is entered.
-   *
-   * @event add
-   */
-
-  /**
-   * When allowAnyInput is true, account-text-changed is fired when input text
-   * changed. This is needed so that the reply dialog's save button can be
-   * enabled for arbitrary cc's, which don't need a 'commit'.
-   *
-   * @event account-text-changed
-   */
-
   @property({type: Boolean})
   allowAnyInput = false;
 
@@ -112,21 +99,13 @@
   }
 
   private handleInputCommit(e: AutocompleteCommitEvent) {
-    this.dispatchEvent(
-      new CustomEvent('add', {
-        detail: {value: e.detail.value},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'add', {value: e.detail.value});
     this.input!.focus();
   }
 
   private inputTextChanged() {
     if (this.inputText.length && this.allowAnyInput) {
-      this.dispatchEvent(
-        new CustomEvent('account-text-changed', {bubbles: true, composed: true})
-      );
+      fireEvent(this, 'account-text-changed');
     }
   }
 
@@ -139,4 +118,17 @@
   interface HTMLElementTagNameMap {
     'gr-account-entry': GrAccountEntry;
   }
+  interface HTMLElementEventMap {
+    /**
+     * Fired when an account is entered.
+     */
+    /* prettier-ignore */
+    'add': AddAccountEvent;
+    /**
+     * When allowAnyInput is true, account-text-changed is fired when input text
+     * changed. This is needed so that the reply dialog's save button can be
+     * enabled for arbitrary cc's, which don't need a 'commit'.
+     */
+    'account-text-changed': CustomEvent<void>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index bb0200a..0f85266 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -13,9 +13,9 @@
 import {isSelf, isServiceUser} from '../../../utils/account-util';
 import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
 import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
-import {fireEvent} from '../../../utils/event-util';
+import {fire, fireEvent} from '../../../utils/event-util';
 import {isInvolved} from '../../../utils/change-util';
-import {EventType, ShowAlertEventDetail} from '../../../types/events';
+import {EventType} from '../../../types/events';
 import {LitElement, css, html, TemplateResult} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {classMap} from 'lit/directives/class-map.js';
@@ -364,16 +364,10 @@
     e.stopPropagation();
     if (!this.account._account_id) return;
 
-    this.dispatchEvent(
-      new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
-        detail: {
-          message: 'Saving attention set update ...',
-          dismissOnNavigation: true,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, EventType.SHOW_ALERT, {
+      message: 'Saving attention set update ...',
+      dismissOnNavigation: true,
+    });
 
     // We are deliberately updating the UI before making the API call. It is a
     // risk that we are taking to achieve a better UX for 99.9% of the cases.
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index 10cefd9..34b8f11 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -6,7 +6,7 @@
 import '../gr-cursor-manager/gr-cursor-manager';
 import '../../../styles/shared-styles';
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
-import {fireEvent} from '../../../utils/event-util';
+import {fire, fireEvent} from '../../../utils/event-util';
 import {Key} from '../../../utils/dom-util';
 import {FitController} from '../../lit/fit-controller';
 import {css, html, LitElement, PropertyValues} from 'lit';
@@ -274,32 +274,20 @@
   // private but used in tests
   handleTab() {
     if (this.isSuggestionListInteractible()) {
-      this.dispatchEvent(
-        new CustomEvent<ItemSelectedEventDetail>('item-selected', {
-          detail: {
-            trigger: 'tab',
-            selected: this.cursor.target,
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, 'item-selected', {
+        trigger: 'tab',
+        selected: this.cursor.target,
+      });
     }
   }
 
   // private but used in tests
   handleEnter() {
     if (this.isSuggestionListInteractible()) {
-      this.dispatchEvent(
-        new CustomEvent<ItemSelectedEventDetail>('item-selected', {
-          detail: {
-            trigger: 'enter',
-            selected: this.cursor.target,
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, 'item-selected', {
+        trigger: 'enter',
+        selected: this.cursor.target,
+      });
     }
   }
 
@@ -318,16 +306,10 @@
       }
       selected = selected.parentElement!;
     }
-    this.dispatchEvent(
-      new CustomEvent<ItemSelectedEventDetail>('item-selected', {
-        detail: {
-          trigger: 'click',
-          selected,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'item-selected', {
+      trigger: 'click',
+      selected,
+    });
   }
 
   private fireClose() {
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index 023513b..9b0fa7b 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -74,13 +74,6 @@
    */
 
   /**
-   * Fired on keydown to allow for custom hooks into autocomplete textbox
-   * behavior.
-   *
-   * @event input-keydown
-   */
-
-  /**
    * Query for requesting autocomplete suggestions. The function should
    * accept the input as a string parameter and return a promise. The
    * promise yields an array of suggestion objects with "name", "label",
@@ -611,13 +604,6 @@
         this.resetQueryOutput();
         this.activeQueryId = 0;
     }
-    this.dispatchEvent(
-      new CustomEvent('input-keydown', {
-        detail: {key: e.key, input: this.input},
-        composed: true,
-        bubbles: true,
-      })
-    );
   }
 
   cancel() {
@@ -720,13 +706,7 @@
     // 'commit' event
     await this.updateComplete;
     if (!silent) {
-      this.dispatchEvent(
-        new CustomEvent('commit', {
-          detail: {value} as AutocompleteCommitEventDetail,
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, 'commit', {value});
     }
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
index c59b8e8..0cef331 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
@@ -1063,27 +1063,14 @@
     });
   });
 
-  test('input-keydown event fired', async () => {
-    const listener = sinon.spy();
-    element.addEventListener('input-keydown', listener);
-    pressKey(inputEl(), Key.TAB);
-    await element.updateComplete;
-    assert.isTrue(listener.called);
-  });
-
   test('enter with modifier does not complete', async () => {
-    const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
     const commitStub = sinon.stub(element, 'handleInputCommit');
+
     pressKey(inputEl(), Key.ENTER, Modifier.CTRL_KEY);
     await element.updateComplete;
 
-    assert.equal(dispatchEventStub.lastCall.args[0].type, 'input-keydown');
-    assert.equal(
-      (dispatchEventStub.lastCall.args[0] as CustomEvent).detail.key,
-      Key.ENTER
-    );
-
     assert.isFalse(commitStub.called);
+
     pressKey(inputEl(), Key.ENTER);
     await element.updateComplete;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
index 0bb451c..54fb825 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -14,6 +14,7 @@
 import {resolve} from '../../../models/dependency';
 import {shortcutsServiceToken} from '../../../services/shortcuts/shortcuts-service';
 import {assertIsDefined} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -93,12 +94,6 @@
       change: this.change,
       starred: newVal,
     };
-    this.dispatchEvent(
-      new CustomEvent('toggle-star', {
-        bubbles: true,
-        composed: true,
-        detail,
-      })
-    );
+    fire(this, 'toggle-star', detail);
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index c844d42..c36226a 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -275,6 +275,9 @@
         this.save();
       });
     }
+    this.addEventListener('open-user-suggest-preview', e => {
+      this.handleShowFix(e.detail.code);
+    });
     this.messagePlaceholder = 'Mention others with @';
     subscribe(
       this,
@@ -524,7 +527,6 @@
             ${this.renderCommentMessage()}
             <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
             ${this.renderHumanActions()} ${this.renderRobotActions()}
-            ${this.renderSuggestEditActions()}
           </div>
         </div>
       </gr-endpoint-decorator>
@@ -776,32 +778,13 @@
     return html`
       <div class="rightActions">
         ${this.autoSaving ? html`.&nbsp;&nbsp;` : ''}
-        ${this.renderDiscardButton()} ${this.renderPreviewSuggestEditButton()}
-        ${this.renderEditButton()} ${this.renderCancelButton()}
-        ${this.renderSaveButton()} ${this.renderCopyLinkIcon()}
+        ${this.renderDiscardButton()} ${this.renderEditButton()}
+        ${this.renderCancelButton()} ${this.renderSaveButton()}
+        ${this.renderCopyLinkIcon()}
       </div>
     `;
   }
 
-  private renderPreviewSuggestEditButton() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
-      return nothing;
-    }
-    assertIsDefined(this.comment, 'comment');
-    if (!hasUserSuggestion(this.comment)) return nothing;
-    return html`
-      <gr-button
-        link
-        secondary
-        class="action show-fix"
-        ?disabled=${this.saving}
-        @click=${this.handleShowFix}
-      >
-        Preview Fix
-      </gr-button>
-    `;
-  }
-
   private renderSuggestEditButton() {
     if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
       return nothing;
@@ -892,22 +875,6 @@
     `;
   }
 
-  private renderSuggestEditActions() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
-      return nothing;
-    }
-    if (
-      !this.account ||
-      isRobot(this.comment) ||
-      isDraftOrUnsaved(this.comment)
-    ) {
-      return nothing;
-    }
-    return html`
-      <div class="robotActions">${this.renderPreviewSuggestEditButton()}</div>
-    `;
-  }
-
   private renderShowFixButton() {
     if (!(this.comment as RobotCommentInfo)?.fix_suggestions) return;
     return html`
@@ -1037,12 +1004,14 @@
   }
 
   // private, but visible for testing
-  async createFixPreview(): Promise<OpenFixPreviewEventDetail> {
+  async createFixPreview(
+    replacement?: string
+  ): Promise<OpenFixPreviewEventDetail> {
     assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
     assertIsDefined(this.comment?.path, 'comment.path');
 
-    if (hasUserSuggestion(this.comment)) {
-      const replacement = getUserSuggestion(this.comment);
+    if (hasUserSuggestion(this.comment) || replacement) {
+      replacement = replacement ?? getUserSuggestion(this.comment);
       assert(!!replacement, 'malformed user suggestion');
       const line = await this.getCommentedCode();
 
@@ -1150,9 +1119,9 @@
     fire(this, 'reply-to-comment', eventDetail);
   }
 
-  private async handleShowFix() {
+  private async handleShowFix(replacement?: string) {
     // Handled top-level in the diff and change view components.
-    fire(this, 'open-fix-preview', await this.createFixPreview());
+    fire(this, 'open-fix-preview', await this.createFixPreview(replacement));
   }
 
   async createSuggestEdit(e: MouseEvent) {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 3390369..6625844 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -29,19 +29,12 @@
 import {
   createComment,
   createDraft,
-  createFixSuggestionInfo,
   createRobotComment,
   createUnsaved,
 } from '../../../test/test-data-generators';
-import {
-  ReplyToCommentEvent,
-  OpenFixPreviewEventDetail,
-} from '../../../types/events';
+import {ReplyToCommentEvent} from '../../../types/events';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
-import {
-  DraftInfo,
-  USER_SUGGESTION_START_PATTERN,
-} from '../../../utils/comment-util';
+import {DraftInfo} from '../../../utils/comment-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {Modifier} from '../../../utils/dom-util';
 import {SinonStub} from 'sinon';
@@ -747,23 +740,6 @@
       actions = query(element, '.robotActions gr-button.fix');
       assert.isNotOk(actions);
     });
-
-    test('handleShowFix fires open-fix-preview event', async () => {
-      const listener = listenOnce<CustomEvent<OpenFixPreviewEventDetail>>(
-        element,
-        'open-fix-preview'
-      );
-      element.comment = {
-        ...createRobotComment(),
-        fix_suggestions: [{...createFixSuggestionInfo()}],
-      };
-      await element.updateComplete;
-
-      queryAndAssert<GrButton>(element, '.show-fix').click();
-
-      const e = await listener;
-      assert.deepEqual(e.detail, await element.createFixPreview());
-    });
   });
 
   suite('auto saving', () => {
@@ -869,33 +845,5 @@
         </gr-button> `
       );
     });
-
-    test('renders preview suggest fix', async () => {
-      element.comment = {
-        ...createComment(),
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-        path: 'test',
-        message: `${USER_SUGGESTION_START_PATTERN}afterSuggestion${'\n```'}`,
-      };
-      await element.updateComplete;
-
-      assert.dom.equal(
-        queryAndAssert(element, 'gr-button.show-fix'),
-        /* HTML */ `<gr-button
-          aria-disabled="false"
-          class="action show-fix"
-          link=""
-          role="button"
-          secondary
-          tabindex="0"
-        >
-          Preview Fix
-        </gr-button> `
-      );
-    });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
index 285a41a..1047b87 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
@@ -11,6 +11,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
+import {fireEventNoBubble, fireNoBubble} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -108,23 +109,12 @@
   private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        detail: {reason: this.message},
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'confirm', {reason: this.message});
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEventNoBubble(this, 'cancel');
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index 350aa7f..8c280c0 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -66,7 +66,10 @@
           color: var(--primary-text-color);
         }
         gr-icon {
-          color: var(--deemphasized-text-color);
+          color: var(
+            --gr-copy-clipboard-icon-color,
+            var(--deemphasized-text-color)
+          );
         }
         gr-button {
           display: block;
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
index 04d5923..c5ad676 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -10,6 +10,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {when} from 'lit/directives/when.js';
+import {fireEventNoBubble} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -199,23 +200,13 @@
 
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEventNoBubble(this, 'confirm');
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEventNoBubble(this, 'cancel');
   }
 
   _handleKeydown(e: KeyboardEvent) {
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index b6ca9f5..c935e72 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -23,6 +23,7 @@
 import {incrementalRepeat} from '../../lit/incremental-repeat';
 import {when} from 'lit/directives/when.js';
 import {isMagicPath} from '../../../utils/path-list-util';
+import {fireNoBubble} from '../../../utils/event-util';
 
 /**
  * Required values are text and value. mobileText and triggerText will
@@ -303,12 +304,7 @@
     this.text = selectedObj.triggerText
       ? selectedObj.triggerText
       : selectedObj.text;
-    this.dispatchEvent(
-      new CustomEvent('value-change', {
-        detail: {value: this.value},
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'value-change', {value: this.value});
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index c78a513..495b448 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -461,13 +461,7 @@
     const item = this.items.find(item => item.id === id);
     if (id && !this.disabledIds.includes(id)) {
       if (item) {
-        this.dispatchEvent(
-          new CustomEvent('tap-item', {
-            detail: item,
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fire(this, 'tap-item', item);
       }
       this.dispatchEvent(new CustomEvent('tap-item-' + id));
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index e176598..01ebb27 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -386,13 +386,7 @@
 
   handleSave(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('editable-content-save', {
-        detail: {content: this.newContent},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'editable-content-save', {content: this.newContent});
     // It would be nice, if we would set this.newContent = undefined here,
     // but we can only do that when we are sure that the save operation has
     // succeeded.
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index 8ca8820..c8574cd 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -22,6 +22,7 @@
 import {IronInputElement} from '@polymer/iron-input';
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {ValueChangedEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -309,13 +310,8 @@
       this.value = this.inputText || '';
     }
     this.editing = false;
-    this.dispatchEvent(
-      new CustomEvent('changed', {
-        detail: this.value,
-        composed: true,
-        bubbles: true,
-      })
-    );
+    // TODO: This event seems to be unused (no listener). Remove?
+    fire(this, 'changed', this.value);
   }
 
   private cancel() {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 45eca40..023d8b5 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -18,6 +18,10 @@
 import {CommentLinks, EmailAddress} from '../../../api/rest-api';
 import {linkifyUrlsAndApplyRewrite} from '../../../utils/link-util';
 import '../gr-account-chip/gr-account-chip';
+import '../gr-user-suggestion-fix/gr-user-suggestion-fix';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {getAppContext} from '../../../services/app-context';
+import {USER_SUGGESTION_INFO_STRING} from '../../../utils/comment-util';
 
 /**
  * This element optionally renders markdown and also applies some regex
@@ -34,6 +38,8 @@
   @state()
   private repoCommentLinks: CommentLinks = {};
 
+  private readonly flagsService = getAppContext().flagsService;
+
   private readonly getConfigModel = resolve(this, configModelToken);
 
   // Private const but used in tests.
@@ -134,6 +140,10 @@
   }
 
   private renderAsMarkdown() {
+    // need to find out here, since customRender is not arrow function
+    const suggestEditsEnable = this.flagsService.isEnabled(
+      KnownExperimentId.SUGGEST_EDIT
+    );
     // <marked-element> internals will be in charge of calling our custom
     // renderer so we wrap 'this.rewriteText' so that 'this' is preserved via
     // closure.
@@ -167,7 +177,18 @@
         `![${text}](${href})`;
       renderer['codespan'] = (text: string) =>
         `<code>${unescapeHTML(text)}</code>`;
-      renderer['code'] = (text: string) => `<pre><code>${text}</code></pre>`;
+      renderer['code'] = (text: string, infostring: string) => {
+        if (suggestEditsEnable && infostring === USER_SUGGESTION_INFO_STRING) {
+          // default santizer in markedjs is very restrictive, we need to use
+          // existing html element to mark element. We cannot use css class for it.
+          // Therefore we pick mark - as not frequently used html element to represent
+          // unconverted gr-user-suggestion-fix.
+          // TODO(milutin): Find a way to override sanitizer to directly use gr-user-suggestion-fix
+          return `<mark>${text}</mark>`;
+        } else {
+          return `<pre><code>${text}</code></pre>`;
+        }
+      };
       renderer['text'] = boundRewriteText;
     }
 
@@ -211,6 +232,9 @@
   override updated() {
     // Look for @mentions and replace them with an account-label chip.
     this.convertEmailsToAccountChips();
+    if (this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+      this.convertCodeToSuggestions();
+    }
   }
 
   private convertEmailsToAccountChips() {
@@ -235,6 +259,17 @@
       }
     }
   }
+
+  private convertCodeToSuggestions() {
+    for (const userSuggestionMark of this.renderRoot.querySelectorAll('mark')) {
+      const userSuggestion = document.createElement('gr-user-suggestion-fix');
+      userSuggestion.textContent = userSuggestionMark.textContent ?? '';
+      userSuggestionMark.parentNode?.replaceChild(
+        userSuggestion,
+        userSuggestionMark
+      );
+    }
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index fcebeea..0e5117a 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -587,5 +587,25 @@
         `
       );
     });
+
+    suite('user suggest fix', () => {
+      setup(async () => {
+        const flagsService = getAppContext().flagsService;
+        sinon.stub(flagsService, 'isEnabled').returns(true);
+      });
+
+      test('renders', async () => {
+        element.content = '```suggestion\nHello World```';
+        await element.updateComplete;
+        assert.shadowDom.equal(
+          element,
+          /* HTML */ `<marked-element>
+            <div class="markdown-html" slot="markdown-html">
+              <gr-user-suggestion-fix>Hello World</gr-user-suggestion-fix>
+            </div>
+          </marked-element>`
+        );
+      });
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index 0628d2f..c81f586 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -4,13 +4,13 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {RevisionInfo, ChangeInfo, RequestPayload} from '../../../types/common';
-import {EventType, ShowAlertEventDetail} from '../../../types/events';
 import {PluginApi} from '../../../api/plugin';
 import {UIActionInfo} from './gr-change-actions-js-api';
 import {windowLocationReload} from '../../../utils/dom-util';
 import {PopupPluginApi} from '../../../api/popup';
 import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
 import {getAppContext} from '../../../services/app-context';
+import {fireAlert} from '../../../utils/event-util';
 
 interface ButtonCallBacks {
   onclick: (event: Event) => boolean;
@@ -110,13 +110,7 @@
       .send(this.action.method, this.action.__url, payload)
       .then(onSuccess)
       .catch((error: unknown) => {
-        document.dispatchEvent(
-          new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
-            detail: {
-              message: `Plugin network error: ${error}`,
-            },
-          })
-        );
+        fireAlert(document, `Plugin network error: ${error}`);
       });
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 8b3e2a6..615a04a 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -17,7 +17,11 @@
 } from '../../../../types/common';
 import {HttpMethod} from '../../../../constants/constants';
 import {RpcLogEventDetail} from '../../../../types/events';
-import {fireNetworkError, fireServerError} from '../../../../utils/event-util';
+import {
+  fire,
+  fireNetworkError,
+  fireServerError,
+} from '../../../../utils/event-util';
 import {FetchRequest} from '../../../../types/types';
 import {ErrorCallback} from '../../../../api/rest';
 import {Scheduler, Task} from '../../../../services/scheduler/scheduler';
@@ -337,13 +341,7 @@
         elapsed,
         anonymizedUrl: req.anonymizedUrl,
       };
-      document.dispatchEvent(
-        new CustomEvent('gr-rpc-log', {
-          detail,
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(document, 'gr-rpc-log', detail);
     }
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
new file mode 100644
index 0000000..c557acc
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
@@ -0,0 +1,97 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement} from 'lit/decorators.js';
+import {getAppContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {fire} from '../../../utils/event-util';
+
+declare global {
+  interface HTMLElementEventMap {
+    'open-user-suggest-preview': OpenUserSuggestionPreviewEvent;
+  }
+}
+
+export type OpenUserSuggestionPreviewEvent =
+  CustomEvent<OpenUserSuggestionPreviewEventDetail>;
+export interface OpenUserSuggestionPreviewEventDetail {
+  code: string;
+}
+
+@customElement('gr-user-suggestion-fix')
+export class GrUserSuggetionFix extends LitElement {
+  private readonly flagsService = getAppContext().flagsService;
+
+  static override styles = [
+    css`
+      .header {
+        background-color: var(--user-suggestion-header-background);
+        color: var(--user-suggestion-header-color);
+        border: 1px solid var(--border-color);
+        border-bottom: 0;
+        padding: var(--spacing-xs) var(--spacing-s);
+        display: flex;
+        align-items: center;
+      }
+      .header .title {
+        flex: 1;
+      }
+      gr-copy-clipboard {
+        --gr-copy-clipboard-icon-color: var(--user-suggestion-header-color);
+      }
+      code {
+        max-width: var(--gr-formatted-text-prose-max-width, none);
+        background-color: var(--background-color-secondary);
+        border: 1px solid var(--border-color);
+        border-top: 0;
+        display: block;
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-code);
+        line-height: var(--line-height-mono);
+        margin-bottom: var(--spacing-m);
+        padding: var(--spacing-xxs) var(--spacing-s);
+        overflow-x: auto;
+        /* Pre will preserve whitespace and line breaks but not wrap */
+        white-space: pre;
+      }
+    `,
+  ];
+
+  override render() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+      return nothing;
+    }
+    if (!this.textContent) return nothing;
+    const code = this.textContent;
+    return html`<div class="header">
+        <div class="title">Suggested fix</div>
+        <div>
+          <gr-copy-clipboard hideInput="" text=${code}></gr-copy-clipboard>
+        </div>
+        <div>
+          <gr-button
+            secondary
+            class="action show-fix"
+            @click=${this.handleShowFix}
+          >
+            Preview Fix
+          </gr-button>
+        </div>
+      </div>
+      <code>${code}</code>`;
+  }
+
+  handleShowFix() {
+    if (!this.textContent) return;
+    fire(this, 'open-user-suggest-preview', {code: this.textContent});
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-user-suggestion-fix': GrUserSuggetionFix;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
new file mode 100644
index 0000000..80422a0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-user-suggestion-fix';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrUserSuggetionFix} from './gr-user-suggestion-fix';
+import {getAppContext} from '../../../services/app-context';
+
+suite('gr-user-suggestion-fix tests', () => {
+  let element: GrUserSuggetionFix;
+
+  setup(async () => {
+    const flagsService = getAppContext().flagsService;
+    sinon.stub(flagsService, 'isEnabled').returns(true);
+    element = await fixture<GrUserSuggetionFix>(html`
+      <gr-user-suggestion-fix>Hello World</gr-user-suggestion-fix>
+    `);
+    await element.updateComplete;
+  });
+
+  test('render', async () => {
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div class="header">
+          <div class="title">Suggested fix</div>
+          <div>
+            <gr-copy-clipboard
+              hideinput=""
+              text="Hello World"
+            ></gr-copy-clipboard>
+          </div>
+          <div>
+            <gr-button class="action show-fix" secondary=""
+              >Preview Fix</gr-button
+            >
+          </div>
+        </div>
+        <code>Hello World</code>`
+    );
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
index 5270603..b3f3714 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
@@ -3,10 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {
-  MovedLinkClickedEventDetail,
-  RenderPreferences,
-} from '../../../api/diff';
+import {RenderPreferences} from '../../../api/diff';
 import {fire} from '../../../utils/event-util';
 import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
@@ -430,16 +427,10 @@
     anchor.setAttribute('href', `#${line}`);
     anchor.addEventListener('click', e => {
       e.preventDefault();
-      anchor.dispatchEvent(
-        new CustomEvent<MovedLinkClickedEventDetail>('moved-link-clicked', {
-          detail: {
-            lineNum: line,
-            side,
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(anchor, 'moved-link-clicked', {
+        lineNum: line,
+        side,
+      });
     });
     return anchor;
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
index d40fdda..e5d3d2e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -9,7 +9,6 @@
   DiffInfo,
   DiffLayer,
   DiffViewMode,
-  MovedLinkClickedEventDetail,
   RenderPreferences,
   Side,
   LineNumber,
@@ -27,6 +26,7 @@
 import '../gr-range-header/gr-range-header';
 import './gr-diff-row';
 import {when} from 'lit/directives/when.js';
+import {fire} from '../../../utils/event-util';
 
 @customElement('gr-diff-section')
 export class GrDiffSection extends LitElement {
@@ -235,16 +235,11 @@
     side: Side,
     line: number
   ) {
-    anchor?.dispatchEvent(
-      new CustomEvent<MovedLinkClickedEventDetail>('moved-link-clicked', {
-        detail: {
-          lineNum: line,
-          side,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    if (!anchor) return;
+    fire(anchor, 'moved-link-clicked', {
+      lineNum: line,
+      side,
+    });
   }
 }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
index cc16de5..9e3640b 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -9,7 +9,6 @@
   DiffViewMode,
   GrDiffCursor as GrDiffCursorApi,
   LineNumber,
-  LineNumberEventDetail,
   LineSelectedEventDetail,
 } from '../../../api/diff';
 import {ScrollMode, Side} from '../../../constants/constants';
@@ -21,6 +20,7 @@
 import {GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {GrDiff} from '../gr-diff/gr-diff';
+import {fire} from '../../../utils/event-util';
 
 type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
 
@@ -484,16 +484,10 @@
     const address = this.getAddressFor(row, side);
     if (address) {
       const {leftSide, number} = address;
-      row.dispatchEvent(
-        new CustomEvent<LineNumberEventDetail>(event, {
-          detail: {
-            lineNum: number,
-            side: leftSide ? Side.LEFT : Side.RIGHT,
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(row, event, {
+        lineNum: number,
+        side: leftSide ? Side.LEFT : Side.RIGHT,
+      });
     }
   }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
index a2a0d0a..02f2233 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -20,6 +20,7 @@
 } from '../gr-diff/gr-diff-utils';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
 
 interface SidedRange {
   side: Side;
@@ -458,13 +459,9 @@
   }
 
   private fireCreateRangeComment(side: Side, range: CommentRange) {
-    this.diffTable?.dispatchEvent(
-      new CustomEvent<CreateRangeCommentEventDetail>('create-range-comment', {
-        detail: {side, range},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    if (this.diffTable) {
+      fire(this.diffTable, 'create-range-comment', {side, range});
+    }
     this.removeActionBox();
   }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
index 3a3f0f7..cb466c3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -24,15 +24,10 @@
 import {classMap} from 'lit/directives/class-map.js';
 import {StyleInfo, styleMap} from 'lit/directives/style-map.js';
 
-import {
-  createEvent,
-  Dimensions,
-  fitToFrame,
-  FrameConstrainer,
-  Point,
-  Rect,
-} from './util';
+import {Dimensions, fitToFrame, FrameConstrainer, Point, Rect} from './util';
 import {ValueChangedEvent} from '../../../types/events';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
+import {ImageDiffAction} from '../../../api/diff';
 
 const DRAG_DEAD_ZONE_PIXELS = 5;
 
@@ -687,27 +682,25 @@
       });
   }
 
+  fireAction(detail: ImageDiffAction) {
+    fireNoBubbleNoCompose(this, 'image-diff-action', detail);
+  }
+
   selectBase() {
     if (!this.baseUrl) return;
     this.baseSelected = true;
-    this.dispatchEvent(
-      createEvent({type: 'version-switcher-clicked', button: 'base'})
-    );
+    this.fireAction({type: 'version-switcher-clicked', button: 'base'});
   }
 
   selectRevision() {
     if (!this.revisionUrl) return;
     this.baseSelected = false;
-    this.dispatchEvent(
-      createEvent({type: 'version-switcher-clicked', button: 'revision'})
-    );
+    this.fireAction({type: 'version-switcher-clicked', button: 'revision'});
   }
 
   manualBlink() {
     this.toggleImage();
-    this.dispatchEvent(
-      createEvent({type: 'version-switcher-clicked', button: 'switch'})
-    );
+    this.fireAction({type: 'version-switcher-clicked', button: 'switch'});
   }
 
   private toggleImage() {
@@ -718,9 +711,10 @@
 
   toggleAutomaticBlink() {
     this.automaticBlink = !this.automaticBlink;
-    this.dispatchEvent(
-      createEvent({type: 'automatic-blink-changed', value: this.automaticBlink})
-    );
+    this.fireAction({
+      type: 'automatic-blink-changed',
+      value: this.automaticBlink,
+    });
   }
 
   private updateAutomaticBlink() {
@@ -752,13 +746,11 @@
 
   private toggleHighlight(source: 'controls' | 'magnifier') {
     this.showHighlight = !this.showHighlight;
-    this.dispatchEvent(
-      createEvent({
-        type: 'highlight-changes-changed',
-        value: this.showHighlight,
-        source,
-      })
-    );
+    this.fireAction({
+      type: 'highlight-changes-changed',
+      value: this.showHighlight,
+      source,
+    });
   }
 
   zoomControlChanged(event: ValueChangedEvent<'fit' | number>) {
@@ -766,38 +758,30 @@
     if (!value) return;
     if (value === 'fit') {
       this.scaledSelected = true;
-      this.dispatchEvent(
-        createEvent({type: 'zoom-level-changed', scale: 'fit'})
-      );
+      this.fireAction({type: 'zoom-level-changed', scale: 'fit'});
     }
     if (typeof value === 'number' && value > 0) {
       this.scaledSelected = false;
       this.scale = value;
-      this.dispatchEvent(
-        createEvent({type: 'zoom-level-changed', scale: value})
-      );
+      this.fireAction({type: 'zoom-level-changed', scale: value});
     }
     this.updateSizes();
   }
 
   followMouseChanged() {
     this.followMouse = !this.followMouse;
-    this.dispatchEvent(
-      createEvent({type: 'follow-mouse-changed', value: this.followMouse})
-    );
+    this.fireAction({type: 'follow-mouse-changed', value: this.followMouse});
   }
 
   pickColor(value: string) {
     this.checkerboardSelected = false;
     this.backgroundColor = value;
-    this.dispatchEvent(createEvent({type: 'background-color-changed', value}));
+    this.fireAction({type: 'background-color-changed', value});
   }
 
   pickCheckerboard() {
     this.checkerboardSelected = true;
-    this.dispatchEvent(
-      createEvent({type: 'background-color-changed', value: 'checkerboard'})
-    );
+    this.fireAction({type: 'background-color-changed', value: 'checkerboard'});
   }
 
   mousemoveImageArea(event: MouseEvent) {
@@ -850,9 +834,9 @@
     // external mice.
     if (distance < DRAG_DEAD_ZONE_PIXELS) {
       this.toggleImage();
-      this.dispatchEvent(createEvent({type: 'magnifier-clicked'}));
+      this.fireAction({type: 'magnifier-clicked'});
     } else {
-      this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
+      this.fireAction({type: 'magnifier-dragged'});
     }
   }
 
@@ -895,7 +879,7 @@
     if (!this.ownsMouseDown) return;
     this.grabbing = false;
     this.ownsMouseDown = false;
-    this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
+    this.fireAction({type: 'magnifier-dragged'});
   }
 
   dragstartMagnifier(event: DragEvent) {
@@ -956,4 +940,7 @@
   interface HTMLElementTagNameMap {
     'gr-image-viewer': GrImageViewer;
   }
+  interface HTMLElementEventMap {
+    'image-diff-action': CustomEvent<ImageDiffAction>;
+  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
index b9354eb..21a7cf8 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -7,8 +7,9 @@
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {StyleInfo, styleMap} from 'lit/directives/style-map.js';
 import {ImageDiffAction} from '../../../api/diff';
+import {fire} from '../../../utils/event-util';
 
-import {createEvent, Dimensions, fitToFrame, Point, Rect} from './util';
+import {Dimensions, fitToFrame, Point, Rect} from './util';
 
 /**
  * Displays a scaled-down version of an image with a draggable frame for
@@ -243,7 +244,7 @@
     const detail: ImageDiffAction = {
       type: this.dragging ? 'overview-frame-dragged' : 'overview-image-clicked',
     };
-    this.dispatchEvent(createEvent(detail));
+    fire(this, 'image-diff-action', detail);
 
     this.dragging = false;
     this.closeOverlay();
@@ -297,13 +298,7 @@
   }
 
   private notifyNewCenter(center: Point) {
-    this.dispatchEvent(
-      new CustomEvent<Point>('center-updated', {
-        detail: {...center},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fire(this, 'center-updated', {...center});
   }
 }
 
@@ -311,4 +306,7 @@
   interface HTMLElementTagNameMap {
     'gr-overview-image': GrOverviewImage;
   }
+  interface HTMLElementEventMap {
+    'center-updated': CustomEvent<Point>;
+  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
index 38a07b7..896dc11 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
@@ -3,7 +3,6 @@
  * Copyright 2021 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {ImageDiffAction} from '../../../api/diff';
 
 export interface Point {
   x: number;
@@ -224,13 +223,3 @@
     };
   }
 }
-
-export function createEvent(
-  detail: ImageDiffAction
-): CustomEvent<ImageDiffAction> {
-  return new CustomEvent('image-diff-action', {
-    detail,
-    bubbles: true,
-    composed: true,
-  });
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index c04549b..0f4ab2e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -54,7 +54,6 @@
   RenderPreferences,
   GrDiff as GrDiffApi,
   DisplayLine,
-  LineSelectedEventDetail,
 } from '../../../api/diff';
 import {isSafari, toggleClass} from '../../../utils/dom-util';
 import {assertIsDefined} from '../../../utils/common-util';
@@ -1349,17 +1348,11 @@
   }
 
   private dispatchSelectedLine(number: LineNumber, side: Side) {
-    this.dispatchEvent(
-      new CustomEvent<LineSelectedEventDetail>('line-selected', {
-        detail: {
-          number,
-          side,
-          path: this.path,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'line-selected', {
+      number,
+      side,
+      path: this.path,
+    });
   }
 
   addDraftAtLine(el: Element) {
@@ -1411,18 +1404,12 @@
     if (!contentEl) throw new Error('content el not found for line el');
     side = side ?? this.getCommentSideByLineAndContent(lineEl, contentEl);
     assertIsDefined(this.path, 'path');
-    this.dispatchEvent(
-      new CustomEvent<CreateCommentEventDetail>('create-comment', {
-        bubbles: true,
-        composed: true,
-        detail: {
-          path: this.path,
-          side,
-          lineNum,
-          range,
-        },
-      })
-    );
+    fire(this, 'create-comment', {
+      path: this.path,
+      side,
+      lineNum,
+      range,
+    });
   }
 
   private getCommentSideByLineAndContent(
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
index 480484e..37c2311 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
@@ -3,6 +3,7 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import {fire} from '../../utils/event-util';
 import {
   AuthRequestInit,
   AuthService,
@@ -28,14 +29,10 @@
   private _setStatus(status: AuthStatus) {
     if (this._status === status) return;
     if (this._status === AuthStatus.AUTHED) {
-      document.dispatchEvent(
-        new CustomEvent('auth-error', {
-          detail: {
-            message: Auth.CREDS_EXPIRED_MSG,
-            action: 'Refresh credentials',
-          },
-        })
-      );
+      fire(document, 'auth-error', {
+        message: Auth.CREDS_EXPIRED_MSG,
+        action: 'Refresh credentials',
+      });
     }
     this._status = status;
   }
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 107ee16..0503e4c 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -278,6 +278,11 @@
     --robot-comment-background-color: var(--blue-50);
     --unresolved-comment-background-color: #fef7e0;
 
+
+    /* Suggest edits */
+    --user-suggestion-header-background: var(--gray-700);
+    --user-suggestion-header-color: white;
+
     /* vote background colors */
     --vote-color-approved: var(--green-300);
     --vote-color-disliked: var(--red-50);
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index a183c86..dc3d4e9 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -138,6 +138,10 @@
     --robot-comment-background-color: #1e3a5f;
     --unresolved-comment-background-color: #614a19;
 
+    /* Suggest edits */
+    --user-suggestion-header-background: var(--gray-700);
+    --user-suggestion-header-color: white;
+
     /* vote background colors */
     --vote-color-approved: var(--green-300);
     --vote-color-disliked: var(--red-tonal);
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index d8a4d2c..c28aade 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -15,7 +15,6 @@
   BIND_VALUE_CHANGED = 'bind-value-changed',
   CHANGE = 'change',
   CHANGED = 'changed',
-  CHANGE_MESSAGE_DELETED = 'change-message-deleted',
   COMMIT = 'commit',
   DIALOG_CHANGE = 'dialog-change',
   DROP = 'drop',
@@ -49,7 +48,6 @@
     'change': ChangeEvent;
     /* prettier-ignore */
     'changed': ChangedEvent;
-    'change-message-deleted': ChangeMessageDeletedEvent;
     /* prettier-ignore */
     'commit': AutocompleteCommitEvent;
     'dialog-change': DialogChangeEvent;
@@ -68,8 +66,6 @@
     /* prettier-ignore */
     'reload': ReloadEvent;
     'remove-reviewer': RemoveReviewerEvent;
-    /* prettier-ignore */
-    'reply': ReplyEvent;
     'show-alert': ShowAlertEvent;
     'show-error': ShowErrorEvent;
     'show-tab': SwitchTabEvent;
@@ -93,6 +89,11 @@
   }
 }
 
+export interface AddAccountEventDetail {
+  value: string;
+}
+export type AddAccountEvent = CustomEvent<AddAccountEventDetail>;
+
 export interface AddReviewerEventDetail {
   reviewer: AccountInfo;
 }
@@ -110,7 +111,8 @@
 
 export type ChangeEvent = InputEvent;
 
-export type ChangedEvent = CustomEvent<string>;
+// TODO: This event seems to be unused (no listener). Remove?
+export type ChangedEvent = CustomEvent<string | undefined>;
 
 export interface ChangeMessageDeletedEventDetail {
   message: ChangeMessage;
@@ -181,6 +183,7 @@
   userWantsToEdit: boolean;
   unresolved: boolean;
 }
+
 export type ReplyToCommentEvent = CustomEvent<ReplyToCommentEventDetail>;
 
 export interface PageErrorEventDetail {
@@ -193,6 +196,11 @@
 }
 export type ReloadEvent = CustomEvent<ReloadEventDetail>;
 
+export interface RemoveAccountEventDetail {
+  account: AccountInfo;
+}
+export type RemoveAccountEvent = CustomEvent<RemoveAccountEventDetail>;
+
 export interface ReplyEventDetail {
   message: ChangeMessage;
 }
@@ -218,6 +226,14 @@
 }
 export type ShowErrorEvent = CustomEvent<ShowErrorEventDetail>;
 
+export interface ShowReplyDialogEventDetail {
+  value: {
+    reviewersOnly: boolean;
+    ccsOnly: boolean;
+  };
+}
+export type ShowReplyDialogEvent = CustomEvent<ShowReplyDialogEventDetail>;
+
 export interface AuthErrorEventDetail {
   message: string;
   action: string;
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index a92f0f8..34a90de 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -523,7 +523,8 @@
   };
 }
 
-export const USER_SUGGESTION_START_PATTERN = '```suggestion\n';
+export const USER_SUGGESTION_INFO_STRING = 'suggestion';
+export const USER_SUGGESTION_START_PATTERN = `\`\`\`${USER_SUGGESTION_INFO_STRING}\n`;
 
 // This can either mean a user or a checks provided fix.
 // "Provided" means that the fix is sent along with the request
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 49d5382..d45ef55 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -20,6 +20,24 @@
   );
 }
 
+export function fireEventNoBubble(target: EventTarget, type: string) {
+  target.dispatchEvent(
+    new CustomEvent(type, {
+      composed: true,
+      bubbles: false,
+    })
+  );
+}
+
+export function fireEventNoBubbleNoCompose(target: EventTarget, type: string) {
+  target.dispatchEvent(
+    new CustomEvent(type, {
+      composed: false,
+      bubbles: false,
+    })
+  );
+}
+
 export type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
   HTMLElementEventMap[K] extends CustomEvent<infer DT>
     ? unknown extends DT
@@ -56,10 +74,42 @@
   );
 }
 
+export function fireNoBubble<K extends keyof HTMLElementEventMap, T>(
+  target: EventTarget,
+  type: K,
+  detail: T
+) {
+  target.dispatchEvent(
+    new CustomEvent<T>(type, {
+      detail,
+      composed: true,
+      bubbles: false,
+    })
+  );
+}
+
+export function fireNoBubbleNoCompose<K extends keyof HTMLElementEventMap, T>(
+  target: EventTarget,
+  type: K,
+  detail: T
+) {
+  target.dispatchEvent(
+    new CustomEvent<T>(type, {
+      detail,
+      composed: false,
+      bubbles: false,
+    })
+  );
+}
+
 export function fireAlert(target: EventTarget, message: string) {
   fire(target, EventType.SHOW_ALERT, {message, showDismiss: true});
 }
 
+export function fireError(target: EventTarget, message: string) {
+  fire(target, EventType.SHOW_ERROR, {message});
+}
+
 export function firePageError(response?: Response | null) {
   if (response === null) response = undefined;
   fire(document, EventType.PAGE_ERROR, {response});