diff --git a/plugins/leaf/skills/leaf/CLAUDE.md b/plugins/leaf/skills/leaf/CLAUDE.md index 8a136caba..7ae685cf5 100644 --- a/plugins/leaf/skills/leaf/CLAUDE.md +++ b/plugins/leaf/skills/leaf/CLAUDE.md @@ -1777,6 +1777,13 @@ document listener, and an `aria-expanded` control fires nothing anywhere. Both keep that state in an attribute, so one `MutationObserver` over `open` and `aria-expanded` repaints for both, and `shadowStage` hands it each root. +State, and not the write that carries it: the watch compares each record against +the attribute's current value and repaints only where the two differ. The paint +restates both attributes on the controls it owns, so a watch reading every record +as news repaints for its own writing, and the page runs at its refresh rate with +nobody touching it. Nothing on screen says so — what said it was the suite, whose +every browser test paid for it until the run went over its bound. + ### Standing somewhere Focus is the reader's current place. `focused` follows it through declared diff --git a/plugins/leaf/skills/leaf/assets/leaf.js b/plugins/leaf/skills/leaf/assets/leaf.js index 71958e0f6..af0abb276 100644 --- a/plugins/leaf/skills/leaf/assets/leaf.js +++ b/plugins/leaf/skills/leaf/assets/leaf.js @@ -460,11 +460,21 @@ function receiveState(...args) { // Where a disclosure keeps which way it stands, in both spellings. Declared up here // because `shadowStage` calls it, far above the key line it repaints for. -const disclosureWatch = new MutationObserver(() => paintHere()); +// A write that says what the attribute already said is not a disclosure changing, and +// taking it for one closes a loop: paintCoreControls paints `aria-expanded` on the key +// line's More control, so every paint scheduled the next one and the page repainted for +// as long as it was open. Reading the old value is what tells the two apart. A real +// toggle still arrives, including one that lands back where it started, because the +// record for its return leg carries the other value. +const disclosureWatch = new MutationObserver((records) => { + if (records.some((r) => r.target.getAttribute(r.attributeName) !== r.oldValue)) + paintHere(); +}); const watchDisclosures = (root) => disclosureWatch.observe(root, { subtree: true, attributeFilter: ["open", "aria-expanded"], + attributeOldValue: true, }); createShadowStage(watchDisclosures); diff --git a/tests/test_render_navigation.py b/tests/test_render_navigation.py index 74a4ac593..fadb87e4f 100644 --- a/tests/test_render_navigation.py +++ b/tests/test_render_navigation.py @@ -2958,6 +2958,51 @@ def test_the_walk_reaches_more_and_goes_on_after_the_line_has_repainted(browser, page.close() +def test_a_page_at_rest_repaints_the_key_line_only_when_the_state_moves(browser, serve): + """A repaint that schedules the next one is a loop no surface reports. + + `paintCoreControls` runs inside `paintHere` and writes what the More control + currently says, `aria-expanded` among it. The runtime watches `open` and + `aria-expanded` over the whole document, because those two attributes are how both + spellings of a disclosure keep which way they stand, and it repaints the line for + either. So the paint delivered its own write back to itself and asked for another + frame, and the page went on repainting for as long as it was open — every browser + test on every page paying for it, which is where it showed: the nightly suite ran + half again as long and the run went over its bound with a fifth of the tests unread. + + Nothing on screen says so, which is why the reading is the page's own frames against + its own state applications. Every application repaints the line and says so through + `lf-actions`, the heartbeat's re-application of state the page already holds + included, so a line that repaints more often than the state moves is repainting for + a reason the page has not got.""" + page, errors = open_page(browser, serve(NOTED_PAGE, comments=2)) + page.evaluate( + """() => { + const probe = { frames: 0, paints: 0, applied: 0 }; + window.__lfProbe = probe; + document.addEventListener("lf-actions", () => { probe.applied += 1; }); + new MutationObserver(() => { probe.paints += 1; }).observe( + document.querySelector(".lf-keyline"), + { attributes: true, childList: true, subtree: true }, + ); + const tick = () => { probe.frames += 1; requestAnimationFrame(tick); }; + requestAnimationFrame(tick); + }""" + ) + # The window is the page's own frames rather than a duration: a loop of this shape + # repaints once per frame whatever the machine's speed, so counting frames is what + # makes the contrast the same size on a loaded runner as on a desk. + page.wait_for_function("() => window.__lfProbe.frames >= 90") + probe = page.evaluate("() => window.__lfProbe") + + assert probe["paints"] <= probe["applied"] + 1, ( + "the key line repainted without the state moving over " + f"{probe['frames']} frames: {probe}" + ) + assert errors == [] + page.close() + + def test_escape_backs_out_from_a_control_nothing_is_typed_into(browser, serve): """A scope takes the keys it uses, so a control that has no Escape of its own leaves the rung standing behind it. The banner's version chooser swallowed it,