diff --git a/web_src/css/modules/animations.css b/web_src/css/modules/animations.css
index 3f5e8bd26..dcd0e0753 100644
--- a/web_src/css/modules/animations.css
+++ b/web_src/css/modules/animations.css
@@ -4,20 +4,22 @@
 }
 
 .is-loading {
-  background: transparent !important;
-  color: transparent !important;
-  border: transparent !important;
   pointer-events: none !important;
   position: relative !important;
   overflow: hidden !important;
 }
 
+.is-loading > * {
+  opacity: 0.3;
+}
+
 .is-loading::after {
   content: "";
   position: absolute;
   display: block;
-  width: 4rem;
   height: 4rem;
+  max-height: 50%;
+  aspect-ratio: 1 / 1;
   left: 50%;
   top: 50%;
   transform: translate(-50%, -50%);
@@ -28,18 +30,24 @@
   border-radius: 100%;
 }
 
+.is-loading.small-loading-icon::after {
+  border-width: 2px;
+}
+
 .markup pre.is-loading,
 .editor-loading.is-loading,
 .pdf-content.is-loading {
   height: var(--height-loading);
 }
 
+/* TODO: not needed, use "is-loading small-loading-icon" instead */
 .btn-octicon.is-loading::after {
   border-width: 2px;
   height: 1.25rem;
   width: 1.25rem;
 }
 
+/* TODO: not needed, use "is-loading small-loading-icon" instead */
 code.language-math.is-loading::after {
   padding: 0;
   border-width: 2px;
@@ -47,11 +55,6 @@ code.language-math.is-loading::after {
   height: 1.25rem;
 }
 
-#oauth2-login-navigator.is-loading::after {
-  width: 40px;
-  height: 40px;
-}
-
 @keyframes fadein {
   0% {
     opacity: 0;
diff --git a/web_src/css/modules/tippy.css b/web_src/css/modules/tippy.css
index bd55b9d6b..fe3259728 100644
--- a/web_src/css/modules/tippy.css
+++ b/web_src/css/modules/tippy.css
@@ -29,6 +29,12 @@
   color: var(--color-text);
 }
 
+.tippy-box[data-theme="form-fetch-error"] {
+  border-color: var(--color-error-border);
+  background-color: var(--color-error-bg);
+  color: var(--color-error-text);
+}
+
 .tippy-content {
   position: relative;
   padding: 1rem;
diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js
index b1d3fa22d..c0e66be51 100644
--- a/web_src/js/features/common-global.js
+++ b/web_src/js/features/common-global.js
@@ -7,6 +7,7 @@ import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.js';
 import {svg} from '../svg.js';
 import {hideElem, showElem, toggleElem} from '../utils/dom.js';
 import {htmlEscape} from 'escape-goat';
+import {createTippy} from '../modules/tippy.js';
 
 const {appUrl, csrfToken, i18n} = window.config;
 
@@ -60,6 +61,81 @@ export function initGlobalButtonClickOnEnter() {
   });
 }
 
+async function formFetchAction(e) {
+  if (!e.target.classList.contains('form-fetch-action')) return;
+
+  e.preventDefault();
+  const formEl = e.target;
+  if (formEl.classList.contains('is-loading')) return;
+
+  formEl.classList.add('is-loading');
+  if (formEl.clientHeight < 50) {
+    formEl.classList.add('small-loading-icon');
+  }
+
+  const formMethod = formEl.getAttribute('method') || 'get';
+  const formActionUrl = formEl.getAttribute('action');
+  const formData = new FormData(formEl);
+  const [submitterName, submitterValue] = [e.submitter?.getAttribute('name'), e.submitter?.getAttribute('value')];
+  if (submitterName) {
+    formData.append(submitterName, submitterValue || '');
+  }
+
+  let reqUrl = formActionUrl;
+  const reqOpt = {method: formMethod.toUpperCase(), headers: {'X-Csrf-Token': csrfToken}};
+  if (formMethod.toLowerCase() === 'get') {
+    const params = new URLSearchParams();
+    for (const [key, value] of formData) {
+      params.append(key, value.toString());
+    }
+    const pos = reqUrl.indexOf('?');
+    if (pos !== -1) {
+      reqUrl = reqUrl.slice(0, pos);
+    }
+    reqUrl += `?${params.toString()}`;
+  } else {
+    reqOpt.body = formData;
+  }
+
+  let errorTippy;
+  const onError = (msg) => {
+    formEl.classList.remove('is-loading', 'small-loading-icon');
+    if (errorTippy) errorTippy.destroy();
+    errorTippy = createTippy(formEl, {
+      content: msg,
+      interactive: true,
+      showOnCreate: true,
+      hideOnClick: true,
+      role: 'alert',
+      theme: 'form-fetch-error',
+      trigger: 'manual',
+      arrow: false,
+    });
+  };
+
+  const doRequest = async () => {
+    try {
+      const resp = await fetch(reqUrl, reqOpt);
+      if (resp.status === 200) {
+        const {redirect} = await resp.json();
+        formEl.classList.remove('dirty'); // remove the areYouSure check before reloading
+        if (redirect) {
+          window.location.href = redirect;
+        } else {
+          window.location.reload();
+        }
+      } else {
+        onError(`server error: ${resp.status}`);
+      }
+    } catch (e) {
+      onError(e.error);
+    }
+  };
+
+  // TODO: add "confirm" support like "link-action" in the future
+  await doRequest();
+}
+
 export function initGlobalCommon() {
   // Semantic UI modules.
   const $uiDropdowns = $('.ui.dropdown');
@@ -114,6 +190,8 @@ export function initGlobalCommon() {
     if (btn.classList.contains('loading')) return e.preventDefault();
     btn.classList.add('loading');
   });
+
+  document.addEventListener('submit', formFetchAction);
 }
 
 export function initGlobalDropzone() {
@@ -182,7 +260,7 @@ function linkAction(e) {
   const $this = $(e.target);
   const redirect = $this.attr('data-redirect');
 
-  const request = () => {
+  const doRequest = () => {
     $this.prop('disabled', true);
     $.post($this.attr('data-url'), {
       _csrf: csrfToken
@@ -201,7 +279,7 @@ function linkAction(e) {
 
   const modalConfirmHtml = htmlEscape($this.attr('data-modal-confirm') || '');
   if (!modalConfirmHtml) {
-    request();
+    doRequest();
     return;
   }
 
@@ -220,7 +298,7 @@ function linkAction(e) {
   $modal.appendTo(document.body);
   $modal.modal({
     onApprove() {
-      request();
+      doRequest();
     },
     onHidden() {
       $modal.remove();
diff --git a/web_src/js/features/comp/QuickSubmit.js b/web_src/js/features/comp/QuickSubmit.js
index d598a5965..2587375a7 100644
--- a/web_src/js/features/comp/QuickSubmit.js
+++ b/web_src/js/features/comp/QuickSubmit.js
@@ -1,17 +1,24 @@
 import $ from 'jquery';
 
 export function handleGlobalEnterQuickSubmit(target) {
-  const $target = $(target);
-  const $form = $(target).closest('form');
-  if ($form.length) {
+  const form = target.closest('form');
+  if (form) {
+    if (!form.checkValidity()) {
+      form.reportValidity();
+      return;
+    }
+
+    if (form.classList.contains('form-fetch-action')) {
+      form.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true}));
+      return;
+    }
+
     // here use the event to trigger the submit event (instead of calling `submit()` method directly)
     // otherwise the `areYouSure` handler won't be executed, then there will be an annoying "confirm to leave" dialog
-    if ($form[0].checkValidity()) {
-      $form.trigger('submit');
-    }
+    $(form).trigger('submit');
   } else {
     // if no form, then the editor is for an AJAX request, dispatch an event to the target, let the target's event handler to do the AJAX request.
     // the 'ce-' prefix means this is a CustomEvent
-    $target.trigger('ce-quick-submit');
+    target.dispatchEvent(new CustomEvent('ce-quick-submit', {bubbles: true}));
   }
 }
diff --git a/web_src/js/features/repo-code.js b/web_src/js/features/repo-code.js
index 6a01a8445..306f38829 100644
--- a/web_src/js/features/repo-code.js
+++ b/web_src/js/features/repo-code.js
@@ -111,7 +111,7 @@ function showLineButton() {
     hideOnClick: true,
     content: menu,
     placement: 'right-start',
-    interactive: 'true',
+    interactive: true,
     onShow: (tippy) => {
       tippy.popper.addEventListener('click', () => {
         tippy.hide();
diff --git a/web_src/js/modules/tippy.js b/web_src/js/modules/tippy.js
index b424cdfd5..3409e1c71 100644
--- a/web_src/js/modules/tippy.js
+++ b/web_src/js/modules/tippy.js
@@ -3,6 +3,11 @@ import tippy from 'tippy.js';
 const visibleInstances = new Set();
 
 export function createTippy(target, opts = {}) {
+  const {role, content, onHide: optsOnHide, onDestroy: optsOnDestroy, onShow: optOnShow} = opts;
+  delete opts.onHide;
+  delete opts.onDestroy;
+  delete opts.onShow;
+
   const instance = tippy(target, {
     appendTo: document.body,
     animation: false,
@@ -13,9 +18,11 @@ export function createTippy(target, opts = {}) {
     maxWidth: 500, // increase over default 350px
     onHide: (instance) => {
       visibleInstances.delete(instance);
+      return optsOnHide?.(instance);
     },
     onDestroy: (instance) => {
       visibleInstances.delete(instance);
+      return optsOnDestroy?.(instance);
     },
     onShow: (instance) => {
       // hide other tooltip instances so only one tooltip shows at a time
@@ -25,18 +32,19 @@ export function createTippy(target, opts = {}) {
         }
       }
       visibleInstances.add(instance);
+      return optOnShow?.(instance);
     },
     arrow: `
`,
     role: 'menu', // HTML role attribute, only tooltips should use "tooltip"
-    theme: opts.role || 'menu', // CSS theme, we support either "tooltip" or "menu"
+    theme: role || 'menu', // CSS theme, we support either "tooltip" or "menu"
     ...opts,
   });
 
   // for popups where content refers to a DOM element, we use the 'tippy-target' class
   // to initially hide the content, now we can remove it as the content has been removed
   // from the DOM by tippy
-  if (opts.content instanceof Element) {
-    opts.content.classList.remove('tippy-target');
+  if (content instanceof Element) {
+    content.classList.remove('tippy-target');
   }
 
   return instance;