chore: refactor legacy JS init code to use "data-global-init" (#39836)

Move the remaining legacy init code, which probed page classes with
`querySelector`, to `data-global-init`, so each feature initializes from
its own element. `hashchange` listeners now go through one module that
runs them in priority order, because independent listeners broke when
mount order changed.

- Convert issue list, diff, repo create, settings and org team inits to
`data-global-init`
- Run `hashchange` listeners by priority, the diff scroll handler last
- Make the devtest mock's failing log requests deterministic
- Add e2e tests for diff tree navigation to folded files and job log
permalinks

---------

Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
wxiaoguangandsilverwind authored and GitHub committed 2026-10-18 13:59:51 +00:00
1 parent ae17c14cff
commit f690ea8efd
26 files changed
+122 -125

No files matched your search

+3 -3
View File
@@ -15,6 +15,7 @@ import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../mo
import {AnsiLineRenderer} from '../render/ansi.ts';
import {createLogLineMessage, parseLogLineCommand} from './ActionRunView.ts';
import type {ActionRunViewStore, LogLine, LogLineCommand} from './ActionRunView.ts';
import {registerAsyncHashChangeListener, removeAsyncHashChangeListener} from '../modules/hashchange.ts';
function isLogElementInViewport(el: Element, {extraViewPortHeight} = {extraViewPortHeight: 0}): boolean {
const rect = el.getBoundingClientRect();
@@ -176,13 +177,12 @@ onMounted(async () => {
});
intervalID = setInterval(() => loadJob(), 1000);
hashChangeListener();
window.addEventListener('hashchange', hashChangeListener);
registerAsyncHashChangeListener(hashChangeListener);
});
onBeforeUnmount(() => {
menuTippy.destroy();
window.removeEventListener('hashchange', hashChangeListener);
removeAsyncHashChangeListener(hashChangeListener);
// clear the interval timer when the component is unmounted
// even our page is rendered once, not spa style
if (intervalID) {
+3 -3
View File
@@ -7,6 +7,7 @@ import {diffTreeStore, filterDiffTree, applyFiltersToFileBoxes, extensionFilterT
import {setFileFolding} from '../features/file-fold.ts';
import {onMounted, onUnmounted, computed, watch} from 'vue';
import {localUserSettings} from '../modules/user-settings.ts';
import {registerAsyncHashChangeListener, removeAsyncHashChangeListener} from '../modules/hashchange.ts';
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
@@ -28,13 +29,12 @@ onMounted(() => {
// while the tree is hidden there is no control to clear a filter restored from the URL
if (store.fileTreeIsVisible) applyFiltersToFileBoxes(store); else store.activeExtensions = 'all';
document.querySelector('.diff-toggle-file-tree-button')!.addEventListener('click', toggleVisibility);
hashChangeListener();
window.addEventListener('hashchange', hashChangeListener);
registerAsyncHashChangeListener(hashChangeListener);
});
onUnmounted(() => {
document.querySelector('.diff-toggle-file-tree-button')!.removeEventListener('click', toggleVisibility);
window.removeEventListener('hashchange', hashChangeListener);
removeAsyncHashChangeListener(hashChangeListener);
});
function hashChangeListener() {
+3 -6
View File
@@ -1,9 +1,7 @@
import {queryElems, toggleElem} from '../utils/dom.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
function initOrgTeamSettings() {
// on the page "page-content organization new team"
const pageContent = document.querySelector('.page-content.organization.new.team');
if (!pageContent) return;
function initOrgTeamSettings(pageContent: HTMLElement) {
queryElems(pageContent, 'input[name=permission]', (el) => el.addEventListener('change', () => {
// Change team access mode
const val = pageContent.querySelector<HTMLInputElement>('input[name=permission]:checked')?.value;
@@ -12,6 +10,5 @@ function initOrgTeamSettings() {
}
export function initOrgTeam() {
if (!document.querySelector('.page-content.organization')) return;
initOrgTeamSettings();
registerGlobalInitFunc('initOrgTeamSettings', initOrgTeamSettings);
}
+2 -2
View File
@@ -1,6 +1,7 @@
import {svg} from '../svg.ts';
import {createTippy} from '../modules/tippy.ts';
import {addDelegatedEventListener} from '../utils/dom.ts';
import {registerAsyncHashChangeListener} from '../modules/hashchange.ts';
function changeHash(hash: string) {
if (window.history.pushState) {
@@ -142,6 +143,5 @@ export function initRepoCodeView() {
showLineButton();
}
};
onHashChange();
window.addEventListener('hashchange', onHashChange);
registerAsyncHashChangeListener(onHashChange);
}
@@ -1,10 +0,0 @@
import {createApp} from 'vue';
import DiffCommitSelector from '../components/DiffCommitSelector.vue';
export function initDiffCommitSelect() {
const el = document.querySelector('#diff-commit-select');
if (!el) return;
const commitSelect = createApp(DiffCommitSelector);
commitSelect.mount(el);
}
-11
View File
@@ -1,11 +0,0 @@
import {createApp} from 'vue';
import DiffFileTree from '../components/DiffFileTree.vue';
import type {DiffFileTreeLocale} from '../modules/diff-file.ts';
export function initDiffFileTree() {
const el = document.querySelector('#diff-file-tree');
if (!el) return;
const locale = JSON.parse(el.getAttribute('data-locale')!) as DiffFileTreeLocale;
createApp(DiffFileTree, {locale}).mount(el);
}
+19 -7
View File
@@ -1,6 +1,6 @@
import {createApp} from 'vue';
import DiffFileTree from '../components/DiffFileTree.vue';
import {initRepoIssueContentHistory} from './repo-issue-content.ts';
import {initDiffFileTree} from './repo-diff-filetree.ts';
import {initDiffCommitSelect} from './repo-diff-commitselect.ts';
import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.ts';
import {initExpandAndCollapseFilesButton, initDiffFileViewedForm} from './pull-view-file.ts';
import {showErrorToast} from '../modules/toast.ts';
@@ -12,8 +12,10 @@ import {invertFileFolding} from './file-fold.ts';
import {parseDom} from '../utils.ts';
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
import {performFetchActionTrigger} from '../modules/fetch-action.ts';
import {applyFiltersToFileBoxes, diffTreeStore} from '../modules/diff-file.ts';
import {applyFiltersToFileBoxes, type DiffFileTreeLocale, diffTreeStore} from '../modules/diff-file.ts';
import {initImageDiff} from './imagediff.ts';
import DiffCommitSelector from '../components/DiffCommitSelector.vue';
import {registerAsyncHashChangeListener} from '../modules/hashchange.ts';
function initDiffFileViewToggle(el: HTMLElement) {
// switch between "rendered" and "source", for image and CSV files
@@ -245,6 +247,15 @@ async function onLocationHashChange() {
}
}
function initDiffFileTree(el: HTMLElement) {
const locale = JSON.parse(el.getAttribute('data-locale')!) as DiffFileTreeLocale;
createApp(DiffFileTree, {locale}).mount(el);
}
function initDiffCommitSelect(el: HTMLElement) {
createApp(DiffCommitSelector).mount(el);
}
export function initRepoDiffView() {
initRepoDiffConversationForm(); // such form appears on the "conversation" page and "diff" page
registerGlobalEventFunc('click', 'diffLoadMoreFiles', (el) => { diffLoadMoreFiles(el) });
@@ -254,13 +265,14 @@ export function initRepoDiffView() {
registerGlobalInitFunc('initDiffFileViewedForm', initDiffFileViewedForm);
registerGlobalInitFunc('initDiffFileImageDiff', initImageDiff);
registerGlobalInitFunc('initDiffFileViewToggle', initDiffFileViewToggle);
registerGlobalInitFunc('initDiffFileTree', initDiffFileTree);
registerGlobalInitFunc('initDiffCommitSelect', initDiffCommitSelect);
if (!document.querySelector('#diff-file-boxes')) return;
initRepoDiffConversationNav(); // "previous" and "next" buttons only appear on "diff" page
initDiffFileTree();
initDiffCommitSelect();
initExpandAndCollapseFilesButton();
window.addEventListener('hashchange', onLocationHashChange);
onLocationHashChange();
// onLocationHashChange does very complicated things (e.g.: load incomplete content and scroll), and it might change the URL hash
// so make it run after all other hash change listeners
registerAsyncHashChangeListener(onLocationHashChange, 999);
}
+18 -38
View File
@@ -1,42 +1,35 @@
import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts';
import {toggleElem, queryElems} from '../utils/dom.ts';
import {html, htmlRaw} from '../utils/html.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {createSortable} from '../modules/sortable.ts';
import {DELETE, POST} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {performFetchAction} from '../modules/fetch-action.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import type {SortableEvent} from 'sortablejs';
type IssuePoster = {avatar_link: string, full_name: string, username: string};
type ProcessedIssuePoster = {type: 'html', html: string};
type IssuePosterResponse = {results: IssuePoster[]};
function initRepoIssueListCheckboxes() {
const issueSelectAll = document.querySelector<HTMLInputElement>('.issue-checkbox-all');
if (!issueSelectAll) return; // logged out state
const issueCheckboxes = document.querySelectorAll<HTMLInputElement>('.issue-checkbox');
function initRepoIssueListCheckboxes(issueSelectAll: HTMLInputElement) {
const container = issueSelectAll.closest('.page-content')!;
const issueCheckboxes = container.querySelectorAll<HTMLInputElement>('.issue-checkbox');
const issueFilters = container.querySelector<HTMLElement>('#issue-filters')!;
const issueActions = container.querySelector<HTMLElement>('#issue-actions')!;
const syncIssueSelectionState = () => {
const checkedCheckboxes = Array.from(issueCheckboxes).filter((el) => el.checked);
const anyChecked = Boolean(checkedCheckboxes.length);
const allChecked = anyChecked && checkedCheckboxes.length === issueCheckboxes.length;
if (allChecked) {
issueSelectAll.checked = true;
issueSelectAll.indeterminate = false;
} else if (anyChecked) {
issueSelectAll.checked = false;
issueSelectAll.indeterminate = true;
} else {
issueSelectAll.checked = false;
issueSelectAll.indeterminate = false;
}
issueSelectAll.checked = allChecked;
issueSelectAll.indeterminate = anyChecked && !allChecked;
// if any issue is selected, show the action panel, otherwise show the filter panel
toggleElem('#issue-filters', !anyChecked);
toggleElem('#issue-actions', anyChecked);
toggleElem(issueFilters, !anyChecked);
toggleElem(issueActions, anyChecked);
// there are two panels but only one select-all checkbox, so move the checkbox to the visible panel
const panels = document.querySelectorAll<HTMLElement>('#issue-filters, #issue-actions');
const visiblePanel = Array.from(panels).find((el) => isElemVisible(el))!;
const visiblePanel = anyChecked ? issueActions : issueFilters;
const toolbarLeft = visiblePanel.querySelector('.issue-list-toolbar-left')!;
toolbarLeft.prepend(issueSelectAll);
};
@@ -52,15 +45,14 @@ function initRepoIssueListCheckboxes() {
syncIssueSelectionState();
});
queryElems(document, '.issue-action', (el) => el.addEventListener('click',
queryElems(container, '.issue-action', (el) => el.addEventListener('click',
async (e: MouseEvent) => {
e.preventDefault();
const url = el.getAttribute('data-url')!;
let action = el.getAttribute('data-action')!;
const elementId = el.getAttribute('data-element-id')!;
const issueIDList: string[] = Array.from(document.querySelectorAll('.issue-checkbox:checked'), (el) => (el.getAttribute('data-issue-id')!));
const issueIDs = issueIDList.join(',');
const issueIDs = Array.from(container.querySelectorAll('.issue-checkbox:checked'), (el) => el.getAttribute('data-issue-id')!).join(',');
if (!issueIDs) return;
// for label toggle
@@ -166,14 +158,7 @@ async function pinMoveEnd(e: SortableEvent) {
await POST(url, {data: {id, position: e.newIndex! + 1}});
}
function initIssuePinSort() {
const pinDiv = document.querySelector<HTMLElement>('#issue-pins');
if (pinDiv === null) return;
// If the User is not a Repo Admin, we don't need to proceed
if (!pinDiv.hasAttribute('data-is-repo-admin')) return;
function initIssuePinSort(pinDiv: HTMLElement) {
initPinRemoveButton();
// If only one issue pinned, we don't need to make this Sortable
@@ -190,12 +175,7 @@ function initIssuePinSort() {
}
export function initRepoIssueList() {
if (document.querySelector('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list')) {
initRepoIssueListCheckboxes();
queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
initIssuePinSort();
} else if (document.querySelector('.page-content.dashboard.issues')) {
// user or org home: issue list, pull request list
queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
}
registerGlobalInitFunc('initRepoIssueListCheckboxes', initRepoIssueListCheckboxes);
registerGlobalInitFunc('initDropdownUserRemoteSearch', initDropdownUserRemoteSearch);
registerGlobalInitFunc('initIssuePinSort', initIssuePinSort);
}
+1 -1
View File
@@ -41,6 +41,7 @@ export function initRepository() {
registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox);
registerGlobalInitFunc('initRepoPullRequestUpdate', initRepoPullRequestUpdate);
registerGlobalInitFunc('initRepoCitationPanel', initRepoCitationPanel);
registerGlobalInitFunc('initRepoNew', initRepoNew);
// TODO: need to refactor more legacy if-querySelector-based "init" code to "data-global-xxx" framework in the future.
const pageContent = document.querySelector('.page-content.repository');
@@ -51,7 +52,6 @@ export function initRepository() {
initRepoCommentForm();
initRepoMilestone();
initRepoNew();
initRepoCloneButtons();
initRepoSettings();
+1 -5
View File
@@ -56,11 +56,7 @@ function initRepoNewTemplateSearch(form: HTMLFormElement) {
onChangeOwner();
}
export function initRepoNew() {
const pageContent = document.querySelector('.page-content.repository.new-repo');
if (!pageContent) return;
const form = document.querySelector<HTMLFormElement>('.new-repo-form')!;
export function initRepoNew(form: HTMLFormElement) {
const inputGitIgnores = form.querySelector<HTMLInputElement>('input[name="gitignores"]')!;
const inputLicense = form.querySelector<HTMLInputElement>('input[name="license"]')!;
const inputAutoInit = form.querySelector<HTMLInputElement>('input[name="auto_init"]')!;
+4 -4
View File
@@ -5,6 +5,7 @@ import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {attachSearchBox} from '../modules/search.ts';
import {globMatch} from '../utils/glob.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
const {appSubUrl} = window.config;
@@ -64,9 +65,8 @@ function initRepoSettingsSearchTeamBox() {
})));
}
function initRepoSettingsGitHook() {
if (!document.querySelector('.page-content.repository.settings.edit.githook')) return;
createCodeEditor(document.querySelector<HTMLTextAreaElement>('#content')!);
function initRepoSettingsGitHook(form: HTMLFormElement) {
createCodeEditor(form.querySelector<HTMLTextAreaElement>('textarea[name=content]')!);
}
function initRepoSettingsBranches() {
@@ -137,11 +137,11 @@ function initRepoSettingsOptions() {
}
export function initRepoSettings() {
registerGlobalInitFunc('initRepoSettingsGitHook', initRepoSettingsGitHook);
if (!document.querySelector('.page-content.repository.settings')) return;
initRepoSettingsOptions();
initRepoSettingsBranches();
initRepoSettingsCollaboration();
initRepoSettingsSearchTeamBox();
initRepoSettingsGitHook();
initRepoSettingsBranchesDrag();
}
+11 -15
View File
@@ -1,19 +1,15 @@
import {hideElem, showElem} from '../utils/dom.ts';
import {toggleElem} from '../utils/dom.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
export function initUserSettings() {
if (!document.querySelector('.user.settings.profile')) return;
const usernameInput = document.querySelector<HTMLInputElement>('#username');
if (!usernameInput) return;
usernameInput.addEventListener('input', function () {
const prompt = document.querySelector('#name-change-prompt')!;
const promptRedirect = document.querySelector('#name-change-redirect-prompt')!;
if (this.value.toLowerCase() !== this.getAttribute('data-name')!.toLowerCase()) {
showElem(prompt);
showElem(promptRedirect);
} else {
hideElem(prompt);
hideElem(promptRedirect);
}
registerGlobalInitFunc('initUserSettingsForm', (elForm: HTMLFormElement) => {
const usernameInput = elForm.querySelector<HTMLInputElement>('input[name=name]')!;
usernameInput.addEventListener('input', () => {
const prompt = elForm.querySelector('#name-change-prompt')!;
const promptRedirect = elForm.querySelector('#name-change-redirect-prompt')!;
const showPrompt = usernameInput.value.toLowerCase() !== usernameInput.getAttribute('data-name')!.toLowerCase();
toggleElem(prompt, showPrompt);
toggleElem(promptRedirect, showPrompt);
});
});
}
+5
View File
@@ -68,6 +68,7 @@ import {initGlobalShortcut} from './modules/shortcut.ts';
import {initDevtest} from './modules/devtest.ts';
import {initRepoWatch} from './features/repo-watch.ts';
import {initPackagesView} from './features/packages.ts';
import {initAsyncHashChangeListeners} from './modules/hashchange.ts';
const initStartTime = performance.now();
const initPerformanceTracer = callInitFunctions([
@@ -177,3 +178,7 @@ if (initDur > 500) {
}
window.config.frontendInited = true;
// then init the async hash change handlers, then they can do async content loading by URL hash
// e.g.: expand hidden comments or load incomplete diff files
initAsyncHashChangeListeners();
+25
View File
@@ -0,0 +1,25 @@
type HashChangeListener = (e: HashChangeEvent) => void | Promise<void>;
const hashChangeListeners: Array<{listener: HashChangeListener, priority: number}> = [];
let hashChangeListenersInited = false;
export function registerAsyncHashChangeListener(listener: HashChangeListener, priority = 0): void {
hashChangeListeners.push({listener, priority});
hashChangeListeners.sort((a, b) => a.priority - b.priority);
if (hashChangeListenersInited) listener(new HashChangeEvent('hashchange')); // late registrants missed the initial run
}
export function removeAsyncHashChangeListener(listener: HashChangeListener): void {
const index = hashChangeListeners.findIndex((item) => item.listener === listener);
if (index !== -1) hashChangeListeners.splice(index, 1);
}
async function runHashChangeListeners(e: HashChangeEvent) {
for (const {listener} of hashChangeListeners) await listener(e);
}
export async function initAsyncHashChangeListeners() {
hashChangeListenersInited = true;
window.addEventListener('hashchange', runHashChangeListeners);
await runHashChangeListeners(new HashChangeEvent('hashchange'));
}