Repository: go-gitea/gitea
Pull request #33755: Refactor global init code and add more comments
Author: login: wxiaoguang
Labels: name: lgtm/done
description: This PR has enough approvals to get merged. There are no important open reservations anymore.
Created: 2025-03-01T03:08:12Z | Merged: 2025-03-03T02:57:28Z

Follow up  #33748 (refactor a lot and let's forget the old code: https://github.com/go-gitea/gitea/pull/33755/files?diff=split&w=0)

Now there are 3 "global" functions:

* registerGlobalSelectorFunc: for all elements matching the selector, eg: `.ui.dropdown`
* registerGlobalInitFunc: for `data-global-init="initInputAutoFocusEnd"`
* registerGlobalEventFunc: for `data-global-click="onCommentReactionButtonClick"`


And introduce `initGlobalInput` to replace old `initAutoFocusEnd` and `attachDirAuto`, use `data-global-init` to replace fragile `.js-autofocus-end` selector.

Another benefit is that by the new approach, no matter how many times `registerGlobalInitFunc` is called, we only need to do one "querySelectorAll" in the last step, it could slightly improve the performance.


Diff:
diff --git a/modules/markup/sanitizer_default_test.go b/modules/markup/sanitizer_default_test.go
index e6fbae50567f4..5282916944dc6 100644
--- a/modules/markup/sanitizer_default_test.go
+++ b/modules/markup/sanitizer_default_test.go
@@ -62,6 +62,10 @@ func TestSanitizer(t *testing.T) {
 		`<a href="javascript:alert('xss')">bad</a>`, `bad`,
 		`<a href="vbscript:no">bad</a>`, `bad`,
 		`<a href="data:1234">bad</a>`, `bad`,
+
+		// Some classes and attributes are used by the frontend framework and will execute JS code, so make sure they are removed
+		`<div class="link-action" data-attr-class="foo" data-url="xxx">txt</div>`, `<div data-attr-class="foo">txt</div>`,
+		`<div class="form-fetch-action" data-markdown-generated-content="bar" data-global-init="a" data-global-click="b">txt</div>`, `<div data-markdown-generated-content="bar">txt</div>`,
 	}
 
 	for i := 0; i < len(testCases); i += 2 {
diff --git a/templates/base/alert.tmpl b/templates/base/alert.tmpl
index 760d3bfa2cc09..3f6d77a64581e 100644
--- a/templates/base/alert.tmpl
+++ b/templates/base/alert.tmpl
@@ -1,20 +1,20 @@
-{{if .Flash.ErrorMsg}}
+{{- if .Flash.ErrorMsg -}}
 	<div class="ui negative message flash-message flash-error">
 		<p>{{.Flash.ErrorMsg | SanitizeHTML}}</p>
 	</div>
-{{end}}
-{{if .Flash.SuccessMsg}}
+{{- end -}}
+{{- if .Flash.SuccessMsg -}}
 	<div class="ui positive message flash-message flash-success">
 		<p>{{.Flash.SuccessMsg | SanitizeHTML}}</p>
 	</div>
-{{end}}
-{{if .Flash.InfoMsg}}
+{{- end -}}
+{{- if .Flash.InfoMsg -}}
 	<div class="ui info message flash-message flash-info">
 		<p>{{.Flash.InfoMsg | SanitizeHTML}}</p>
 	</div>
-{{end}}
-{{if .Flash.WarningMsg}}
+{{- end -}}
+{{- if .Flash.WarningMsg -}}
 	<div class="ui warning message flash-message flash-warning">
 		<p>{{.Flash.WarningMsg | SanitizeHTML}}</p>
 	</div>
-{{end}}
+{{- end -}}
diff --git a/templates/repo/issue/new_form.tmpl b/templates/repo/issue/new_form.tmpl
index dd4c7617ce100..5a923a1602c7e 100644
--- a/templates/repo/issue/new_form.tmpl
+++ b/templates/repo/issue/new_form.tmpl
@@ -1,6 +1,4 @@
-{{if .Flash}}
 {{template "base/alert" .}}
-{{end}}
 <form class="issue-content ui comment form form-fetch-action" id="new-issue" action="{{.Link}}" method="post">
 	{{.CsrfTokenHtml}}
 	<div class="issue-content-left">
@@ -9,7 +7,10 @@
 				{{ctx.AvatarUtils.Avatar .SignedUser 40}}
 				<div class="ui segment content tw-my-0">
 					<div class="field">
-						<input name="title" class="js-autofocus-end" id="issue_title" placeholder="{{ctx.Locale.Tr "repo.milestones.title"}}" value="{{if .TitleQuery}}{{.TitleQuery}}{{else if .IssueTemplateTitle}}{{.IssueTemplateTitle}}{{else}}{{.title}}{{end}}" required maxlength="255" autocomplete="off">
+						<input name="title" data-global-init="initInputAutoFocusEnd" id="issue_title" required maxlength="255" autocomplete="off"
+								placeholder="{{ctx.Locale.Tr "repo.milestones.title"}}"
+								value="{{if .TitleQuery}}{{.TitleQuery}}{{else if .IssueTemplateTitle}}{{.IssueTemplateTitle}}{{else}}{{.title}}{{end}}"
+						>
 						{{if .PageIsComparePull}}
 							<div class="title_wip_desc" data-wip-prefixes="{{JsonUtils.EncodeToString .PullRequestWorkInProgressPrefixes}}">{{ctx.Locale.Tr "repo.pulls.title_wip_desc" (index .PullRequestWorkInProgressPrefixes 0)}}</div>
 						{{end}}
diff --git a/web_src/js/features/autofocus-end.ts b/web_src/js/features/autofocus-end.ts
deleted file mode 100644
index 53e475b543def..0000000000000
--- a/web_src/js/features/autofocus-end.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export function initAutoFocusEnd() {
-  for (const el of document.querySelectorAll<HTMLInputElement>('.js-autofocus-end')) {
-    el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus.
-    el.setSelectionRange(el.value.length, el.value.length);
-  }
-}
diff --git a/web_src/js/features/common-page.ts b/web_src/js/features/common-page.ts
index c5274201f2ab0..235555a73ddba 100644
--- a/web_src/js/features/common-page.ts
+++ b/web_src/js/features/common-page.ts
@@ -2,7 +2,7 @@ import {GET} from '../modules/fetch.ts';
 import {showGlobalErrorMessage} from '../bootstrap.ts';
 import {fomanticQuery} from '../modules/fomantic/base.ts';
 import {queryElems} from '../utils/dom.ts';
-import {observeAddedElement} from '../modules/observer.ts';
+import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
 
 const {appUrl} = window.config;
 
@@ -30,7 +30,7 @@ export function initFootLanguageMenu() {
 
 export function initGlobalDropdown() {
   // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code.
-  observeAddedElement('.ui.dropdown:not(.custom)', (el) => {
+  registerGlobalSelectorFunc('.ui.dropdown:not(.custom)', (el) => {
     const $dropdown = fomanticQuery(el);
     if ($dropdown.data('module-dropdown')) return; // do not re-init if other code has already initialized it.
 
@@ -80,6 +80,25 @@ export function initGlobalTabularMenu() {
   fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab({autoTabActivation: false});
 }
 
+// for performance considerations, it only uses performant syntax
+function attachInputDirAuto(el: Partial<HTMLInputElement | HTMLTextAreaElement>) {
+  if (el.type !== 'hidden' &&
+    el.type !== 'checkbox' &&
+    el.type !== 'radio' &&
+    el.type !== 'range' &&
+    el.type !== 'color') {
+    el.dir = 'auto';
+  }
+}
+
+export function initGlobalInput() {
+  registerGlobalSelectorFunc('input, textarea', attachInputDirAuto);
+  registerGlobalInitFunc('initInputAutoFocusEnd', (el: HTMLInputElement) => {
+    el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus.
+    el.setSelectionRange(el.value.length, el.value.length);
+  });
+}
+
 /**
  * Too many users set their ROOT_URL to wrong value, and it causes a lot of problems:
  *   * Cross-origin API request without correct cookie
diff --git a/web_src/js/features/repo-diff.ts b/web_src/js/features/repo-diff.ts
index f1e441a133177..1ecd00f1af6d9 100644
--- a/web_src/js/features/repo-diff.ts
+++ b/web_src/js/features/repo-diff.ts
@@ -10,7 +10,7 @@ import {POST, GET} from '../modules/fetch.ts';
 import {createTippy} from '../modules/tippy.ts';
 import {invertFileFolding} from './file-fold.ts';
 import {parseDom} from '../utils.ts';
-import {observeAddedElement} from '../modules/observer.ts';
+import {registerGlobalSelectorFunc} from '../modules/observer.ts';
 
 const {i18n} = window.config;
 
@@ -254,7 +254,7 @@ export function initRepoDiffView() {
   initExpandAndCollapseFilesButton();
   initRepoDiffHashChangeListener();
 
-  observeAddedElement('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);
+  registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);
   addDelegatedEventListener(document, 'click', '.fold-file', (el) => {
     invertFileFolding(el.closest('.file-content'), el);
   });
diff --git a/web_src/js/index.ts b/web_src/js/index.ts
index 2e253870c0953..f48074316e702 100644
--- a/web_src/js/index.ts
+++ b/web_src/js/index.ts
@@ -11,7 +11,6 @@ import {initImageDiff} from './features/imagediff.ts';
 import {initRepoMigration} from './features/repo-migration.ts';
 import {initRepoProject} from './features/repo-projects.ts';
 import {initTableSort} from './features/tablesort.ts';
-import {initAutoFocusEnd} from './features/autofocus-end.ts';
 import {initAdminUserListSearchForm} from './features/admin/users.ts';
 import {initAdminConfigs} from './features/admin/config.ts';
 import {initMarkupAnchors} from './markup/anchors.ts';
@@ -62,62 +61,23 @@ import {initRepoContributors} from './features/contributors.ts';
 import {initRepoCodeFrequency} from './features/code-frequency.ts';
 import {initRepoRecentCommits} from './features/recent-commits.ts';
 import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts';
-import {initAddedElementObserver} from './modules/observer.ts';
+import {initGlobalSelectorObserver} from './modules/observer.ts';
 import {initRepositorySearch} from './features/repo-search.ts';
 import {initColorPickers} from './features/colorpicker.ts';
 import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
 import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
 import {initGlobalFetchAction} from './features/common-fetch-action.ts';
-import {
-  initFootLanguageMenu,
-  initGlobalDropdown,
-  initGlobalTabularMenu,
-  initHeadNavbarContentToggle,
-} from './features/common-page.ts';
-import {
-  initGlobalButtonClickOnEnter,
-  initGlobalButtons,
-  initGlobalDeleteButton,
-} from './features/common-button.ts';
-import {
-  initGlobalComboMarkdownEditor,
-  initGlobalEnterQuickSubmit,
-  initGlobalFormDirtyLeaveConfirm,
-} from './features/common-form.ts';
+import {initFootLanguageMenu, initGlobalDropdown, initGlobalInput, initGlobalTabularMenu, initHeadNavbarContentToggle} from './features/common-page.ts';
+import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts';
+import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
+import {callInitFunctions} from './modules/init.ts';
 
 initGiteaFomantic();
-initAddedElementObserver();
 initSubmitEventPolyfill();
 
-function callInitFunctions(functions: (() => any)[]) {
-  // Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
-  // It is a quick check, no side effect so no need to do slow URL parsing.
-  const initStart = performance.now();
-  if (window.location.search.includes('_ui_performance_trace=1')) {
-    let results: {name: string, dur: number}[] = [];
-    for (const func of functions) {
-      const start = performance.now();
-      func();
-      results.push({name: func.name, dur: performance.now() - start});
-    }
-    results = results.sort((a, b) => b.dur - a.dur);
-    for (let i = 0; i < 20 && i < results.length; i++) {
-      // eslint-disable-next-line no-console
-      console.log(`performance trace: ${results[i].name} ${results[i].dur.toFixed(3)}`);
-    }
-  } else {
-    for (const func of functions) {
-      func();
-    }
-  }
-  const initDur = performance.now() - initStart;
-  if (initDur > 500) {
-    console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
-  }
-}
-
 onDomReady(() => {
-  callInitFunctions([
+  const initStartTime = performance.now();
+  const initPerformanceTracer = callInitFunctions([
     initGlobalDropdown,
     initGlobalTabularMenu,
     initGlobalFetchAction,
@@ -129,6 +89,7 @@ onDomReady(() => {
     initGlobalFormDirtyLeaveConfirm,
     initGlobalComboMarkdownEditor,
     initGlobalDeleteButton,
+    initGlobalInput,
 
     initCommonOrganization,
     initCommonIssueListQuickGoto,
@@ -150,7 +111,6 @@ onDomReady(() => {
     initSshKeyFormParser,
     initStopwatch,
     initTableSort,
-    initAutoFocusEnd,
     initFindFileInRepo,
     initCopyContent,
 
@@ -212,4 +172,13 @@ onDomReady(() => {
 
     initOAuth2SettingsDisableCheckbox,
   ]);
+
+  // it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.
+  initGlobalSelectorObserver(initPerformanceTracer);
+  if (initPerformanceTracer) initPerformanceTracer.printResults();
+
+  const initDur = performance.now() - initStartTime;
+  if (initDur > 500) {
+    console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
+  }
 });
diff --git a/web_src/js/modules/init.ts b/web_src/js/modules/init.ts
new file mode 100644
index 0000000000000..538fafd83fa6c
--- /dev/null
+++ b/web_src/js/modules/init.ts
@@ -0,0 +1,26 @@
+export class InitPerformanceTracer {
+  results: {name: string, dur: number}[] = [];
+  recordCall(name: string, func: ()=>void) {
+    const start = performance.now();
+    func();
+    this.results.push({name, dur: performance.now() - start});
+  }
+  printResults() {
+    this.results = this.results.sort((a, b) => b.dur - a.dur);
+    for (let i = 0; i < 20 && i < this.results.length; i++) {
+      console.info(`performance trace: ${this.results[i].name} ${this.results[i].dur.toFixed(3)}`);
+    }
+  }
+}
+
+export function callInitFunctions(functions: (() => any)[]): InitPerformanceTracer | null {
+  // Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
+  // It is a quick check, no side effect so no need to do slow URL parsing.
+  const perfTracer = !window.location.search.includes('_ui_performance_trace=1') ? null : new InitPerformanceTracer();
+  if (perfTracer) {
+    for (const func of functions) perfTracer.recordCall(func.name, func);
+  } else {
+    for (const func of functions) func();
+  }
+  return perfTracer;
+}
diff --git a/web_src/js/modules/observer.ts b/web_src/js/modules/observer.ts
index 57d0db31c7fb9..f60c033cf2e71 100644
--- a/web_src/js/modules/observer.ts
+++ b/web_src/js/modules/observer.ts
@@ -1,52 +1,73 @@
 import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
+import type {Promisable} from 'type-fest';
+import type {InitPerformanceTracer} from './init.ts';
 
-type DirElement = HTMLInputElement | HTMLTextAreaElement;
+let globalSelectorObserverInited = false;
 
-// for performance considerations, it only uses performant syntax
-function attachDirAuto(el: Partial<DirElement>) {
-  if (el.type !== 'hidden' &&
-      el.type !== 'checkbox' &&
-      el.type !== 'radio' &&
-      el.type !== 'range' &&
-      el.type !== 'color') {
-    el.dir = 'auto';
+type SelectorHandler = {selector: string, handler: (el: HTMLElement) => void};
+const selectorHandlers: SelectorHandler[] = [];
+
+type GlobalEventFunc<T extends HTMLElement, E extends Event> = (el: T, e: E) => Promisable<void>;
+const globalEventFuncs: Record<string, GlobalEventFunc<HTMLElement, Event>> = {};
+
+type GlobalInitFunc<T extends HTMLElement> = (el: T) => Promisable<void>;
+const globalInitFuncs: Record<string, GlobalInitFunc<HTMLElement>> = {};
+
+// It handles the global events for all `<div data-global-click="onSomeElemClick"></div>` elements.
+export function registerGlobalEventFunc<T extends HTMLElement, E extends Event>(event: string, name: string, func: GlobalEventFunc<T, E>) {
+  globalEventFuncs[`${event}:${name}`] = func as GlobalEventFunc<HTMLElement, Event>;
+}
+
+// It handles the global init functions by a selector, for example:
+// > registerGlobalSelectorObserver('.ui.dropdown:not(.custom)', (el) => { initDropdown(el, ...) });
+export function registerGlobalSelectorFunc(selector: string, handler: (el: HTMLElement) => void) {
+  selectorHandlers.push({selector, handler});
+  // Then initAddedElementObserver will call this handler for all existing elements after all handlers are added.
+  // This approach makes the init stage only need to do one "querySelectorAll".
+  if (!globalSelectorObserverInited) return;
+  for (const el of document.querySelectorAll<HTMLElement>(selector)) {
+    handler(el);
   }
 }
 
-type GlobalInitFunc<T extends HTMLElement> = (el: T) => void | Promise<void>;
-const globalInitFuncs: Record<string, GlobalInitFunc<HTMLElement>> = {};
-function attachGlobalInit(el: HTMLElement) {
+// It handles the global init functions for all `<div data-global-int="initSomeElem"></div>` elements.
+export function registerGlobalInitFunc<T extends HTMLElement>(name: string, handler: GlobalInitFunc<T>) {
+  globalInitFuncs[name] = handler as GlobalInitFunc<HTMLElement>;
+  // The "global init" functions are managed internally and called by callGlobalInitFunc
+  // They must be ready before initGlobalSelectorObserver is called.
+  if (globalSelectorObserverInited) throw new Error('registerGlobalInitFunc() must be called before initGlobalSelectorObserver()');
+}
+
+function callGlobalInitFunc(el: HTMLElement) {
   const initFunc = el.getAttribute('data-global-init');
   const func = globalInitFuncs[initFunc];
   if (!func) throw new Error(`Global init function "${initFunc}" not found`);
+
+  type GiteaGlobalInitElement = Partial<HTMLElement> & {_giteaGlobalInited: boolean};
+  if ((el as GiteaGlobalInitElement)._giteaGlobalInited) throw new Error(`Global init function "${initFunc}" already executed`);
+  (el as GiteaGlobalInitElement)._giteaGlobalInited = true;
   func(el);
 }
 
-type GlobalEventFunc<T extends HTMLElement, E extends Event> = (el: T, e: E) => (void | Promise<void>);
-const globalEventFuncs: Record<string, GlobalEventFunc<HTMLElement, Event>> = {};
-export function registerGlobalEventFunc<T extends HTMLElement, E extends Event>(event: string, name: string, func: GlobalEventFunc<T, E>) {
-  globalEventFuncs[`${event}:${name}`] = func as any;
+function attachGlobalEvents() {
+  // add global "[data-global-click]" event handler
+  document.addEventListener('click', (e) => {
+    const elem = (e.target as HTMLElement).closest<HTMLElement>('[data-global-click]');
+    if (!elem) return;
+    const funcName = elem.getAttribute('data-global-click');
+    const func = globalEventFuncs[`click:${funcName}`];
+    if (!func) throw new Error(`Global event function "click:${funcName}" not found`);
+    func(elem, e);
+  });
 }
 
-type SelectorHandler = {
-  selector: string,
-  handler: (el: HTMLElement) => void,
-};
+export function initGlobalSelectorObserver(perfTracer?: InitPerformanceTracer): void {
+  if (globalSelectorObserverInited) throw new Error('initGlobalSelectorObserver() already called');
+  globalSelectorObserverInited = true;
 
-const selectorHandlers: SelectorHandler[] = [
-  {selector: 'input, textarea', handler: attachDirAuto},
-  {selector: '[data-global-init]', handler: attachGlobalInit},
-];
+  attachGlobalEvents();
 
-export function observeAddedElement(selector: string, handler: (el: HTMLElement) => void) {
-  selectorHandlers.push({selector, handler});
-  const docNodes = document.querySelectorAll<HTMLElement>(selector);
-  for (const el of docNodes) {
-    handler(el);
-  }
-}
-
-export function initAddedElementObserver(): void {
+  selectorHandlers.push({selector: '[data-global-init]', handler: callGlobalInitFunc});
   const observer = new MutationObserver((mutationList) => {
     const len = mutationList.length;
     for (let i = 0; i < len; i++) {
@@ -60,30 +81,27 @@ export function initAddedElementObserver(): void {
           if (addedNode.matches(selector)) {
             handler(addedNode);
           }
-          const children = addedNode.querySelectorAll<HTMLElement>(selector);
-          for (const el of children) {
+          for (const el of addedNode.querySelectorAll<HTMLElement>(selector)) {
             handler(el);
           }
         }
       }
     }
   });
-
-  for (const {selector, handler} of selectorHandlers) {
-    const docNodes = document.querySelectorAll<HTMLElement>(selector);
-    for (const el of docNodes) {
-      handler(el);
+  if (perfTracer) {
+    for (const {selector, handler} of selectorHandlers) {
+      perfTracer.recordCall(`initGlobalSelectorObserver ${selector}`, () => {
+        for (const el of document.querySelectorAll<HTMLElement>(selector)) {
+          handler(el);
+        }
+      });
+    }
+  } else {
+    for (const {selector, handler} of selectorHandlers) {
+      for (const el of document.querySelectorAll<HTMLElement>(selector)) {
+        handler(el);
+      }
     }
   }
-
   observer.observe(document, {subtree: true, childList: true});
-
-  document.addEventListener('click', (e) => {
-    const elem = (e.target as HTMLElement).closest<HTMLElement>('[data-global-click]');
-    if (!elem) return;
-    const funcName = elem.getAttribute('data-global-click');
-    const func = globalEventFuncs[`click:${funcName}`];
-    if (!func) throw new Error(`Global event function "click:${funcName}" not found`);
-    func(elem, e);
-  });
 }
diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts
index 603f967b34a2d..4d15784e6ed2a 100644
--- a/web_src/js/utils/dom.ts
+++ b/web_src/js/utils/dom.ts
@@ -355,7 +355,7 @@ export function querySingleVisibleElem<T extends HTMLElement>(parent: Element, s
   return candidates.length ? candidates[0] as T : null;
 }
 
-export function addDelegatedEventListener<T extends HTMLElement, E extends Event>(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => void | Promise<any>, options?: boolean | AddEventListenerOptions) {
+export function addDelegatedEventListener<T extends HTMLElement, E extends Event>(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => Promisable<void>, options?: boolean | AddEventListenerOptions) {
   parent.addEventListener(type, (e: Event) => {
     const elem = (e.target as HTMLElement).closest(selector);
     if (!elem) return;


Reviews:
user: login: lunny
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-01T04:21:32Z
user: login: silverwind
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-01T07:57:12Z
user: login: silverwind
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-01T07:58:26Z
user: login: wxiaoguang
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-01T10:43:11Z
user: login: silverwind
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-02T16:55:23Z
user: login: silverwind
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-02T16:59:31Z
user: login: wxiaoguang
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-02T23:17:52Z
user: login: wxiaoguang
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-02T23:18:51Z
user: login: wxiaoguang
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-02T23:19:19Z
user: login: kerwin612
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-03T02:55:09Z

Review comments:
diff_hunk: @@ -62,61 +61,47 @@ import {initRepoContributors} from './features/contributors.ts';
 import {initRepoCodeFrequency} from './features/code-frequency.ts';
 import {initRepoRecentCommits} from './features/recent-commits.ts';
 import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts';
-import {initAddedElementObserver} from './modules/observer.ts';
+import {initGlobalSelectorObserver} from './modules/observer.ts';
 import {initRepositorySearch} from './features/repo-search.ts';
 import {initColorPickers} from './features/colorpicker.ts';
 import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
 import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
 import {initGlobalFetchAction} from './features/common-fetch-action.ts';
-import {
-  initFootLanguageMenu,
-  initGlobalDropdown,
-  initGlobalTabularMenu,
-  initHeadNavbarContentToggle,
-} from './features/common-page.ts';
-import {
-  initGlobalButtonClickOnEnter,
-  initGlobalButtons,
-  initGlobalDeleteButton,
-} from './features/common-button.ts';
-import {
-  initGlobalComboMarkdownEditor,
-  initGlobalEnterQuickSubmit,
-  initGlobalFormDirtyLeaveConfirm,
-} from './features/common-form.ts';
+import {initFootLanguageMenu, initGlobalDropdown, initGlobalInput, initGlobalTabularMenu, initHeadNavbarContentToggle} from './features/common-page.ts';
+import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts';
+import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
 
 initGiteaFomantic();
-initAddedElementObserver();
 initSubmitEventPolyfill();
 
-function callInitFunctions(functions: (() => any)[]) {
-  // Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
-  // It is a quick check, no side effect so no need to do slow URL parsing.
-  const initStart = performance.now();
-  if (window.location.search.includes('_ui_performance_trace=1')) {
-    let results: {name: string, dur: number}[] = [];
-    for (const func of functions) {
-      const start = performance.now();
-      func();
-      results.push({name: func.name, dur: performance.now() - start});
-    }
-    results = results.sort((a, b) => b.dur - a.dur);
-    for (let i = 0; i < 20 && i < results.length; i++) {
+// Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
+// It is a quick check, no side effect so no need to do slow URL parsing.
+const traceInitPerformance = !window.location.search.includes('_ui_performance_trace=1') ? null : new class {
+  results: {name: string, dur: number}[] = [];
+  recordCall(name: string, func: ()=>void) {
+    const start = performance.now();
+    func();
+    this.results.push({name, dur: performance.now() - start});
+  }
+  printResults() {
+    this.results = this.results.sort((a, b) => b.dur - a.dur);
+    for (let i = 0; i < 20 && i < this.results.length; i++) {
       // eslint-disable-next-line no-console
-      console.log(`performance trace: ${results[i].name} ${results[i].dur.toFixed(3)}`);
-    }
-  } else {
-    for (const func of functions) {
-      func();
+      console.log(`performance trace: ${this.results[i].name} ${this.results[i].dur.toFixed(3)}`);
     }
   }
-  const initDur = performance.now() - initStart;
-  if (initDur > 500) {
-    console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
+}();
+
+function callInitFunctions(functions: (() => any)[]) {
+  if (traceInitPerformance) {
+    for (const func of functions) traceInitPerformance.recordCall(func.name, func);
+  } else {
+    for (const func of functions) func();
   }
 }
path: web_src/js/index.ts
user: login: silverwind
body: Can these functions be moved out of `index.ts` to like `modules/init.ts`? I prefer index.ts to be kept clean and only call the inits.
created_at: 2025-03-01T07:57:12Z
author_association: MEMBER
diff_hunk: @@ -1,52 +1,71 @@
 import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
 
-type DirElement = HTMLInputElement | HTMLTextAreaElement;
+let globalSelectorObserverInited = false;
 
-// for performance considerations, it only uses performant syntax
-function attachDirAuto(el: Partial<DirElement>) {
-  if (el.type !== 'hidden' &&
-      el.type !== 'checkbox' &&
-      el.type !== 'radio' &&
-      el.type !== 'range' &&
-      el.type !== 'color') {
-    el.dir = 'auto';
-  }
-}
+type SelectorHandler = {selector: string, handler: (el: HTMLElement) => void};
+const selectorHandlers: SelectorHandler[] = [];
+
+type GlobalEventFunc<T extends HTMLElement, E extends Event> = (el: T, e: E) => (void | Promise<void>);
path: web_src/js/modules/observer.ts
user: login: silverwind
body: ```suggestion
type GlobalEventFunc<T extends HTMLElement, E extends Event> = (el: T, e: E) => (Promisable<void>);
```

Import `Promisable` from `type-fest`.

Ref: https://github.com/sindresorhus/type-fest/blob/main/source/promisable.d.ts
created_at: 2025-03-01T07:58:26Z
author_association: MEMBER
diff_hunk: @@ -62,61 +61,47 @@ import {initRepoContributors} from './features/contributors.ts';
 import {initRepoCodeFrequency} from './features/code-frequency.ts';
 import {initRepoRecentCommits} from './features/recent-commits.ts';
 import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts';
-import {initAddedElementObserver} from './modules/observer.ts';
+import {initGlobalSelectorObserver} from './modules/observer.ts';
 import {initRepositorySearch} from './features/repo-search.ts';
 import {initColorPickers} from './features/colorpicker.ts';
 import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
 import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
 import {initGlobalFetchAction} from './features/common-fetch-action.ts';
-import {
-  initFootLanguageMenu,
-  initGlobalDropdown,
-  initGlobalTabularMenu,
-  initHeadNavbarContentToggle,
-} from './features/common-page.ts';
-import {
-  initGlobalButtonClickOnEnter,
-  initGlobalButtons,
-  initGlobalDeleteButton,
-} from './features/common-button.ts';
-import {
-  initGlobalComboMarkdownEditor,
-  initGlobalEnterQuickSubmit,
-  initGlobalFormDirtyLeaveConfirm,
-} from './features/common-form.ts';
+import {initFootLanguageMenu, initGlobalDropdown, initGlobalInput, initGlobalTabularMenu, initHeadNavbarContentToggle} from './features/common-page.ts';
+import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts';
+import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
 
 initGiteaFomantic();
-initAddedElementObserver();
 initSubmitEventPolyfill();
 
-function callInitFunctions(functions: (() => any)[]) {
-  // Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
-  // It is a quick check, no side effect so no need to do slow URL parsing.
-  const initStart = performance.now();
-  if (window.location.search.includes('_ui_performance_trace=1')) {
-    let results: {name: string, dur: number}[] = [];
-    for (const func of functions) {
-      const start = performance.now();
-      func();
-      results.push({name: func.name, dur: performance.now() - start});
-    }
-    results = results.sort((a, b) => b.dur - a.dur);
-    for (let i = 0; i < 20 && i < results.length; i++) {
+// Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
+// It is a quick check, no side effect so no need to do slow URL parsing.
+const traceInitPerformance = !window.location.search.includes('_ui_performance_trace=1') ? null : new class {
+  results: {name: string, dur: number}[] = [];
+  recordCall(name: string, func: ()=>void) {
+    const start = performance.now();
+    func();
+    this.results.push({name, dur: performance.now() - start});
+  }
+  printResults() {
+    this.results = this.results.sort((a, b) => b.dur - a.dur);
+    for (let i = 0; i < 20 && i < this.results.length; i++) {
       // eslint-disable-next-line no-console
-      console.log(`performance trace: ${results[i].name} ${results[i].dur.toFixed(3)}`);
-    }
-  } else {
-    for (const func of functions) {
-      func();
+      console.log(`performance trace: ${this.results[i].name} ${this.results[i].dur.toFixed(3)}`);
     }
   }
-  const initDur = performance.now() - initStart;
-  if (initDur > 500) {
-    console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
+}();
+
+function callInitFunctions(functions: (() => any)[]) {
+  if (traceInitPerformance) {
+    for (const func of functions) traceInitPerformance.recordCall(func.name, func);
+  } else {
+    for (const func of functions) func();
   }
 }
path: web_src/js/index.ts
user: login: wxiaoguang
body: Done in latest commit
created_at: 2025-03-01T10:43:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,27 @@
+export class InitPerformanceTracer {
+  results: {name: string, dur: number}[] = [];
+  recordCall(name: string, func: ()=>void) {
+    const start = performance.now();
+    func();
+    this.results.push({name, dur: performance.now() - start});
+  }
+  printResults() {
+    this.results = this.results.sort((a, b) => b.dur - a.dur);
+    for (let i = 0; i < 20 && i < this.results.length; i++) {
+      // eslint-disable-next-line no-console
+      console.log(`performance trace: ${this.results[i].name} ${this.results[i].dur.toFixed(3)}`);
+    }
path: web_src/js/modules/init.ts
user: login: silverwind
body: ```suggestion
    for (const [index, {name, dur}] of this.results.entries()) {
      if (index >= 20) break;
      console.info(`performance trace: ${name} ${dur.toFixed(3)}`);
    }
```
created_at: 2025-03-02T16:55:23Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,27 @@
+export class InitPerformanceTracer {
+  results: {name: string, dur: number}[] = [];
+  recordCall(name: string, func: ()=>void) {
+    const start = performance.now();
+    func();
+    this.results.push({name, dur: performance.now() - start});
+  }
+  printResults() {
+    this.results = this.results.sort((a, b) => b.dur - a.dur);
+    for (let i = 0; i < 20 && i < this.results.length; i++) {
+      // eslint-disable-next-line no-console
+      console.log(`performance trace: ${this.results[i].name} ${this.results[i].dur.toFixed(3)}`);
+    }
+  }
+}
+
+export function callInitFunctions(functions: (() => any)[]): InitPerformanceTracer | null {
+  // Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
+  // It is a quick check, no side effect so no need to do slow URL parsing.
+  const perfTracer = !window.location.search.includes('_ui_performance_trace=1') ? null : new InitPerformanceTracer();
+  if (perfTracer) {
+    for (const func of functions) perfTracer.recordCall(func.name, func);
+  } else {
+    for (const func of functions) func();
+  }
path: web_src/js/modules/init.ts
user: login: silverwind
body: ```suggestion
  for (const func of functions) {
    perfTracer?.recordCall(func.name, func) ?? func();
  }
```
created_at: 2025-03-02T16:59:31Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,27 @@
+export class InitPerformanceTracer {
+  results: {name: string, dur: number}[] = [];
+  recordCall(name: string, func: ()=>void) {
+    const start = performance.now();
+    func();
+    this.results.push({name, dur: performance.now() - start});
+  }
+  printResults() {
+    this.results = this.results.sort((a, b) => b.dur - a.dur);
+    for (let i = 0; i < 20 && i < this.results.length; i++) {
+      // eslint-disable-next-line no-console
+      console.log(`performance trace: ${this.results[i].name} ${this.results[i].dur.toFixed(3)}`);
+    }
+  }
+}
+
+export function callInitFunctions(functions: (() => any)[]): InitPerformanceTracer | null {
+  // Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
+  // It is a quick check, no side effect so no need to do slow URL parsing.
+  const perfTracer = !window.location.search.includes('_ui_performance_trace=1') ? null : new InitPerformanceTracer();
+  if (perfTracer) {
+    for (const func of functions) perfTracer.recordCall(func.name, func);
+  } else {
+    for (const func of functions) func();
+  }
path: web_src/js/modules/init.ts
user: login: wxiaoguang
body: I do not think it is performant.

By using the `if`, we do not need to check `perfTracer` anymore in the loop
created_at: 2025-03-02T23:17:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,27 @@
+export class InitPerformanceTracer {
+  results: {name: string, dur: number}[] = [];
+  recordCall(name: string, func: ()=>void) {
+    const start = performance.now();
+    func();
+    this.results.push({name, dur: performance.now() - start});
+  }
+  printResults() {
+    this.results = this.results.sort((a, b) => b.dur - a.dur);
+    for (let i = 0; i < 20 && i < this.results.length; i++) {
+      // eslint-disable-next-line no-console
+      console.log(`performance trace: ${this.results[i].name} ${this.results[i].dur.toFixed(3)}`);
+    }
path: web_src/js/modules/init.ts
user: login: wxiaoguang
body: I do not think it is performant, by the `for ( let ...; i < len; ...)` loop, we do not need to create new "entries" object or extra `if` check
created_at: 2025-03-02T23:18:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,27 @@
+export class InitPerformanceTracer {
+  results: {name: string, dur: number}[] = [];
+  recordCall(name: string, func: ()=>void) {
+    const start = performance.now();
+    func();
+    this.results.push({name, dur: performance.now() - start});
+  }
+  printResults() {
+    this.results = this.results.sort((a, b) => b.dur - a.dur);
+    for (let i = 0; i < 20 && i < this.results.length; i++) {
+      // eslint-disable-next-line no-console
+      console.log(`performance trace: ${this.results[i].name} ${this.results[i].dur.toFixed(3)}`);
path: web_src/js/modules/init.ts
user: login: wxiaoguang
body: ```suggestion
      console.info(`performance trace: ${this.results[i].name} ${this.results[i].dur.toFixed(3)}`);
```
created_at: 2025-03-02T23:19:19Z
author_association: CONTRIBUTOR

PR comments:
user: login: wxiaoguang
created_at: 2025-03-01T03:13:16Z
body: The "autofocus-end" and "dir=auto" all work as expected:

<details>

![image](https://github.com/user-attachments/assets/630a796f-05d9-4a19-bd58-85ca7bfb0175)

</details>

And the performance trace:

```
performance trace: initHeatmap 14.500
performance trace: initGlobalSelectorObserver .ui.dropdown:not(.custom) 5.600
performance trace: initDashboardRepoList 3.300
performance trace: initRepoPullRequestReview 0.400
```
author_association: CONTRIBUTOR
user: login: silverwind
created_at: 2025-03-01T07:55:47Z
body: I'm not sure I like the "global" name used here. It seems superflous. Why is it named like that?
author_association: MEMBER
user: login: wxiaoguang
created_at: 2025-03-01T10:29:33Z
body: > I'm not sure I like the "global" name used here. It seems superflous. Why is it named like that?

I think the meaning is accurate. Because there are "global" event handlers and init function callers (they affect all elements), and it matches `data-global-xxx`. We can't use `data-init` or `data-click`, at least, we need some "namespace" to group the function&attribute names.

----

If you have better and accurate names, please advise.

And since these names are unique, even we keep them, we could always be able to easily change them by a search&replace in the future.
author_association: CONTRIBUTOR