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.
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 | |||
| 151 | 151 | const tabs = [node('t0'), node('t1')]; | |
| 152 | 152 | const panels = [node('mr-commits'), node('mr-checks-panel')]; | |
| 153 | 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 | 156 | const nav = node('tabs'); nav.querySelectorAll = () => tabs; | |
| 156 | 157 | nav.querySelector = (s) => s === '[data-checks-label]' ? label : null; | |
| 157 | 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 | 168 | window.ermiteMR.panels(); | |
| 168 | 169 | assert.equal(label.textContent, '1/2 failed', 'initial label sync'); | |
| 169 | 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 | 176 | summary.title = ''; callback(); | |
| 171 | 177 | assert.equal(label.textContent, '1/2', 'observer re-syncs from the text'); | |
| 172 | 178 | 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 = (() => { | |||
| 356 | 356 | let observer; | |
| 357 | 357 | const summary = $('#mr-ck'); | |
| 358 | 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 | 368 | observer = new MutationObserver(sync); | |
| 361 | - | observer.observe(summary.parentElement, { childList: true, subtree: true, characterData: true }); | |
| 369 | + | observer.observe(summary.parentElement, { childList: true }); | |
| 362 | 370 | sync(); | |
| 363 | 371 | } | |
| 364 | 372 | 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(); | ||