detached 89e7d82

fix(web): stop the checks label observer from looping with hx-live

The footer synced its checks label on every mutation under the MR bar,
subtree and text included. hx-live rewrites labels in that bar on every
document mutation, so the two woke each other forever and froze the page.
Watch only the replacement of #mr-ck and write the label when it changes.
commit
89e7d82bbc6f6f83c66788442a18c8ba191e9641
author
brice · · 2026-10-20 07:31 UTC
parent
a034b8c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
3/4
2 files · +17 -3
M internal/adapters/web/jstest/mr.test.mjs +7 -1
@@ -151,7 +151,8 @@ test('footer: #mr-ck syncs the checks label and a click opens the footer on Chec
151151 const tabs = [node('t0'), node('t1')];
152152 const panels = [node('mr-commits'), node('mr-checks-panel')];
153153 const footer = node('mr-panels'); footer.open = false; footer.querySelectorAll = () => panels;
154- const label = { textContent: '' };
154+ let writes = 0, text = '';
155+ const label = { get textContent() { return text; }, set textContent(v) { writes++; text = v; } };
155156 const nav = node('tabs'); nav.querySelectorAll = () => tabs;
156157 nav.querySelector = (s) => s === '[data-checks-label]' ? label : null;
157158 const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} };
@@ -167,6 +168,11 @@ test('footer: #mr-ck syncs the checks label and a click opens the footer on Chec
167168 window.ermiteMR.panels();
168169 assert.equal(label.textContent, '1/2 failed', 'initial label sync');
169170 assert.equal(observed[0], summary.parentElement);
171+ // A mutation elsewhere in the bar (hx-live rewriting a label) must not
172+ // write the label again, or hx-live wakes up on its own write forever.
173+ assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer');
174+ const before = writes; callback(); callback();
175+ assert.equal(writes, before, 'an unchanged label is not written');
170176 summary.title = ''; callback();
171177 assert.equal(label.textContent, '1/2', 'observer re-syncs from the text');
172178 summary.textContent = ' '; callback();
@@ -151,7 +151,8 @@ test('footer: #mr-ck syncs the checks label and a click opens the footer on Chec
151 const tabs = [node('t0'), node('t1')];151 const tabs = [node('t0'), node('t1')];
152 const panels = [node('mr-commits'), node('mr-checks-panel')];152 const panels = [node('mr-commits'), node('mr-checks-panel')];
153 const footer = node('mr-panels'); footer.open = false; footer.querySelectorAll = () => panels;153 const footer = node('mr-panels'); footer.open = false; footer.querySelectorAll = () => panels;
154- const label = { textContent: '' };154+ let writes = 0, text = '';
155+ const label = { get textContent() { return text; }, set textContent(v) { writes++; text = v; } };
155 const nav = node('tabs'); nav.querySelectorAll = () => tabs;156 const nav = node('tabs'); nav.querySelectorAll = () => tabs;
156 nav.querySelector = (s) => s === '[data-checks-label]' ? label : null;157 nav.querySelector = (s) => s === '[data-checks-label]' ? label : null;
157 const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} };158 const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} };
@@ -167,6 +168,11 @@ test('footer: #mr-ck syncs the checks label and a click opens the footer on Chec
167 window.ermiteMR.panels();168 window.ermiteMR.panels();
168 assert.equal(label.textContent, '1/2 failed', 'initial label sync');169 assert.equal(label.textContent, '1/2 failed', 'initial label sync');
169 assert.equal(observed[0], summary.parentElement);170 assert.equal(observed[0], summary.parentElement);
171+ // A mutation elsewhere in the bar (hx-live rewriting a label) must not
172+ // write the label again, or hx-live wakes up on its own write forever.
173+ assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer');
174+ const before = writes; callback(); callback();
175+ assert.equal(writes, before, 'an unchanged label is not written');
170 summary.title = ''; callback();176 summary.title = ''; callback();
171 assert.equal(label.textContent, '1/2', 'observer re-syncs from the text');177 assert.equal(label.textContent, '1/2', 'observer re-syncs from the text');
172 summary.textContent = ' '; callback();178 summary.textContent = ' '; callback();
M internal/adapters/web/static/js/mr.js +10 -2
@@ -356,9 +356,17 @@ window.ermiteMR = (() => {
356356 let observer;
357357 const summary = $('#mr-ck');
358358 if (summary) {
359- const sync = () => { $('[data-checks-label]', nav).textContent = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks'; };
359+ // The live stream replaces #mr-ck as a whole: watch only the children of
360+ // its parent, and write the label only when it changes. hx-live rewrites
361+ // other text in the same bar on every mutation of the document; a write
362+ // here on each of them would wake hx-live again, a loop that never yields.
363+ const sync = () => {
364+ const label = $('[data-checks-label]', nav);
365+ const text = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks';
366+ if (label && label.textContent !== text) label.textContent = text;
367+ };
360368 observer = new MutationObserver(sync);
361- observer.observe(summary.parentElement, { childList: true, subtree: true, characterData: true });
369+ observer.observe(summary.parentElement, { childList: true });
362370 sync();
363371 }
364372 select(0); hash();
@@ -356,9 +356,17 @@ window.ermiteMR = (() => {
356 let observer;356 let observer;
357 const summary = $('#mr-ck');357 const summary = $('#mr-ck');
358 if (summary) {358 if (summary) {
359- const sync = () => { $('[data-checks-label]', nav).textContent = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks'; };359+ // The live stream replaces #mr-ck as a whole: watch only the children of
360+ // its parent, and write the label only when it changes. hx-live rewrites
361+ // other text in the same bar on every mutation of the document; a write
362+ // here on each of them would wake hx-live again, a loop that never yields.
363+ const sync = () => {
364+ const label = $('[data-checks-label]', nav);
365+ const text = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks';
366+ if (label && label.textContent !== text) label.textContent = text;
367+ };
360 observer = new MutationObserver(sync);368 observer = new MutationObserver(sync);
361- observer.observe(summary.parentElement, { childList: true, subtree: true, characterData: true });369+ observer.observe(summary.parentElement, { childList: true });
362 sync();370 sync();
363 }371 }
364 select(0); hash();372 select(0); hash();

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close