From 72d57665dcf23f06ae89bd817897779aeb15f80b Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Mon, 24 Aug 2026 13:12:39 -0400 Subject: [PATCH 1/2] feat: re-measure measured bones on DOM mutations Co-Authored-By: Claude Fable 5 --- .changeset/measured-mutation-observer.md | 5 ++ apps/docs/content/docs/api/bones-boundary.mdx | 2 +- packages/bones/src/element/overlay.ts | 42 +++++++--- packages/bones/tests/browser/mutation.test.ts | 78 +++++++++++++++++++ 4 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 .changeset/measured-mutation-observer.md create mode 100644 packages/bones/tests/browser/mutation.test.ts diff --git a/.changeset/measured-mutation-observer.md b/.changeset/measured-mutation-observer.md new file mode 100644 index 0000000..c222112 --- /dev/null +++ b/.changeset/measured-mutation-observer.md @@ -0,0 +1,5 @@ +--- +"@camp.dev/bones": patch +--- + +Measured bones re-measure when the boundary's children or text change while busy, not only when the boundary resizes. Streamed and htmx-style swaps mid-skeleton now update the bars instead of leaving stale ones. diff --git a/apps/docs/content/docs/api/bones-boundary.mdx b/apps/docs/content/docs/api/bones-boundary.mdx index f4f0dc9..56ac1f1 100644 --- a/apps/docs/content/docs/api/bones-boundary.mdx +++ b/apps/docs/content/docs/api/bones-boundary.mdx @@ -103,7 +103,7 @@ A measured boundary is `display: block; position: relative` (the overlay needs a Three edges to know about: - Content hiding uses a non-`!important` `::slotted` rule. An author rule that sets `visibility` on a direct child of the boundary wins over it and that child stays visible. The same cascade behavior is what lets `auto.css` keep `[data-bones-auto="off"]` subtrees visible under the overlay — without `auto.css` on the page, opted-out subtrees hide with everything else. -- Re-measurement is size-driven. Content that changes while busy without resizing the boundary (a fixed-height container whose children are swapped) keeps its previous bars until the next resize. Content that scrolls inside the boundary (an `overflow: auto` box) also leaves bars at their pre-scroll positions, because scrolling fires no resize. +- Re-measurement is driven by size changes and by DOM changes: children added, removed, or replaced, and text edits. An attribute change that reflows content without either — a `class` swap inside a fixed-size boundary — keeps its previous bars until the next resize or DOM change. Content that scrolls inside the boundary (an `overflow: auto` box) also leaves bars at their pre-scroll positions, because scrolling fires neither. - Measurement compensates for scale transforms on the boundary or its ancestors, but not for rotation or skew. A rotated boundary draws its bars in the wrong place. ## React diff --git a/packages/bones/src/element/overlay.ts b/packages/bones/src/element/overlay.ts index 6fdcab4..65ee133 100644 --- a/packages/bones/src/element/overlay.ts +++ b/packages/bones/src/element/overlay.ts @@ -96,6 +96,7 @@ export class MeasuredOverlay { #host: HTMLElement; #container: HTMLElement | undefined; #observer: ResizeObserver | undefined; + #mutations: MutationObserver | undefined; #active = false; // The author may set data-bones-auto themselves; only remove it on // deactivate when this overlay put it there. @@ -167,21 +168,42 @@ export class MeasuredOverlay { } #observe(): void { - if (typeof ResizeObserver === "undefined" || this.#observer) return; - this.#observer = new ResizeObserver(() => { - // The callback runs after layout, so re-measuring here is sound. The - // bars are absolutely positioned in the shadow tree and never change - // the host's size, so this cannot loop. Content that becomes - // unmeasurable while active (e.g. the subtree emptied out) falls back - // to the CSS path rather than leaving stale bars pinned in place. - if (this.#active && !this.#renderBars()) this.deactivate(); - }); - this.#observer.observe(this.#host); + if (typeof ResizeObserver !== "undefined" && !this.#observer) { + this.#observer = new ResizeObserver(() => this.#invalidate()); + this.#observer.observe(this.#host); + } + // Streamed swaps replace children mid-show without resizing the host, so + // size alone is not enough to invalidate. This observer sees only the + // light tree — bar rendering happens in the shadow root, which a + // light-tree observer never reports — so re-rendering bars cannot + // re-trigger it. Attribute mutations are deliberately excluded: observing + // them would fire on every class or style tick of anything inside the + // boundary, and an attribute-driven reflow inside a fixed-size host is a + // documented stale case instead. + if (typeof MutationObserver !== "undefined" && !this.#mutations) { + this.#mutations = new MutationObserver(() => this.#invalidate()); + this.#mutations.observe(this.#host, { + childList: true, + subtree: true, + characterData: true, + }); + } } #unobserve(): void { this.#observer?.disconnect(); this.#observer = undefined; + this.#mutations?.disconnect(); + this.#mutations = undefined; + } + + // Called after layout (ResizeObserver) or as a microtask after a DOM change + // (MutationObserver); re-measuring forces layout in the second case, which + // is fine for how rarely busy content mutates. Content that becomes + // unmeasurable while active (e.g. the subtree emptied out) falls back to + // the CSS path rather than leaving stale bars pinned in place. + #invalidate(): void { + if (this.#active && !this.#renderBars()) this.deactivate(); } #renderBars(): boolean { diff --git a/packages/bones/tests/browser/mutation.test.ts b/packages/bones/tests/browser/mutation.test.ts new file mode 100644 index 0000000..b707c93 --- /dev/null +++ b/packages/bones/tests/browser/mutation.test.ts @@ -0,0 +1,78 @@ +/// +import { afterEach, expect, test } from "vite-plus/test"; +import "../../src/css/auto.css"; +import "../../src/element/index.ts"; +import type { BonesBoundary } from "../../src/element/index.ts"; + +// --------------------------------------------------------------------------- +// Mutation-driven re-measurement. The host height is pinned in every fixture, +// so a child swap never resizes the host and the ResizeObserver cannot be the +// thing that re-measures — these tests fail without the MutationObserver. +// --------------------------------------------------------------------------- + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function mount(inner: string): BonesBoundary { + document.body.insertAdjacentHTML( + "beforeend", + `${inner}`, + ); + return document.querySelector("bones-boundary")!; +} + +function bars(el: BonesBoundary): HTMLElement[] { + return Array.from(el.shadowRoot!.querySelectorAll('[part~="bone"]')); +} + +function fragment(html: string): DocumentFragment { + return document.createRange().createContextualFragment(html); +} + +const TWO_LINES = '

aaaa bbbb cccc dddd eeee ffff

'; +const ONE_LINE = '

short

'; + +test("swapping children re-measures without a resize", async () => { + const el = mount(TWO_LINES); + expect(bars(el)).toHaveLength(2); + el.replaceChildren(fragment(ONE_LINE)); + await expect.poll(() => bars(el).length).toBe(1); + expect(el.hasAttribute("data-bones-measured")).toBe(true); +}); + +test("editing text re-measures", async () => { + const el = mount(TWO_LINES); + expect(bars(el)).toHaveLength(2); + (el.querySelector("p")!.firstChild as Text).data = "short"; + await expect.poll(() => bars(el).length).toBe(1); +}); + +test("an emptied subtree deactivates to the CSS path", async () => { + const el = mount(TWO_LINES); + el.replaceChildren(); + await expect.poll(() => el.hasAttribute("data-bones-measured")).toBe(false); + expect(bars(el)).toHaveLength(0); +}); + +test("bar rendering does not observe itself", async () => { + // Bars render into the shadow root; an observer on the host's light tree + // never reports them. If it did, this would loop: each re-measure replaces + // the bar elements, which would re-trigger the observer forever. + const el = mount(TWO_LINES); + el.replaceChildren(fragment(ONE_LINE)); + await expect.poll(() => bars(el).length).toBe(1); + // ResizeObserver guarantees one callback the first time a target is + // observed, even with no actual size change (there is no previously + // reported size to compare against) — see #observe in overlay.ts. That + // fires once here too, harmlessly re-rendering the same content. It is + // bounded, not a loop: settle past it (confirmed by measurement to land + // within a couple of frames) before taking the identity baseline, so this + // assertion isolates the thing it's actually testing — no *further* + // self-triggered churn — from that unrelated, one-time delivery. + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const bar = bars(el)[0]; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + expect(bars(el)[0]).toBe(bar); +}); From e4bb74c167c9d302d9c476877ef37bad5c9a4e56 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Mon, 24 Aug 2026 13:22:13 -0400 Subject: [PATCH 2/2] fix: pin mutation tests to the MutationObserver, not RO's first delivery The three re-measure tests mutated in the same tick as mount, so ResizeObserver's spec-mandated first callback (fired regardless of any real resize) supplied the re-measure instead of the MutationObserver under test. Settle past that guaranteed delivery before mutating so only DOM changes can drive the re-measure, add a test pinning that attribute mutations are excluded, and document the empty-then-refilled-subtree edge in the docs bullet per the controller's ruling (overlay.ts behavior unchanged). Co-Authored-By: Claude Fable 5 --- apps/docs/content/docs/api/bones-boundary.mdx | 2 +- packages/bones/tests/browser/mutation.test.ts | 43 +++++++++++++------ 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/apps/docs/content/docs/api/bones-boundary.mdx b/apps/docs/content/docs/api/bones-boundary.mdx index 56ac1f1..0e1c144 100644 --- a/apps/docs/content/docs/api/bones-boundary.mdx +++ b/apps/docs/content/docs/api/bones-boundary.mdx @@ -103,7 +103,7 @@ A measured boundary is `display: block; position: relative` (the overlay needs a Three edges to know about: - Content hiding uses a non-`!important` `::slotted` rule. An author rule that sets `visibility` on a direct child of the boundary wins over it and that child stays visible. The same cascade behavior is what lets `auto.css` keep `[data-bones-auto="off"]` subtrees visible under the overlay — without `auto.css` on the page, opted-out subtrees hide with everything else. -- Re-measurement is driven by size changes and by DOM changes: children added, removed, or replaced, and text edits. An attribute change that reflows content without either — a `class` swap inside a fixed-size boundary — keeps its previous bars until the next resize or DOM change. Content that scrolls inside the boundary (an `overflow: auto` box) also leaves bars at their pre-scroll positions, because scrolling fires neither. +- Re-measurement is driven by size changes and by DOM changes: children added, removed, or replaced, and text edits. An attribute change that reflows content without either — a `class` swap inside a fixed-size boundary — keeps its previous bars until the next resize or DOM change. Content that scrolls inside the boundary (an `overflow: auto` box) also leaves bars at their pre-scroll positions, because scrolling fires neither. A subtree that becomes empty while showing drops the boundary to the CSS path for the rest of that showing window, even if content is added later. - Measurement compensates for scale transforms on the boundary or its ancestors, but not for rotation or skew. A rotated boundary draws its bars in the wrong place. ## React diff --git a/packages/bones/tests/browser/mutation.test.ts b/packages/bones/tests/browser/mutation.test.ts index b707c93..1d85b95 100644 --- a/packages/bones/tests/browser/mutation.test.ts +++ b/packages/bones/tests/browser/mutation.test.ts @@ -6,8 +6,12 @@ import type { BonesBoundary } from "../../src/element/index.ts"; // --------------------------------------------------------------------------- // Mutation-driven re-measurement. The host height is pinned in every fixture, -// so a child swap never resizes the host and the ResizeObserver cannot be the -// thing that re-measures — these tests fail without the MutationObserver. +// so a child swap never resizes the host. ResizeObserver also guarantees one +// callback the first time it observes a target, even with no actual size +// change (there is no previously reported size to compare against yet) — so +// every fixture below settles past that guaranteed first delivery before +// mutating. After the settle, only the MutationObserver can drive a +// re-measure: these tests fail without it (verified — see the fix report). // --------------------------------------------------------------------------- afterEach(() => { @@ -31,12 +35,21 @@ function fragment(html: string): DocumentFragment { return document.createRange().createContextualFragment(html); } +// Lets ResizeObserver's guaranteed first delivery (and any other pending +// microtask/frame work) land and settle before a test's real assertion. +function settle(): Promise { + return new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); +} + const TWO_LINES = '

aaaa bbbb cccc dddd eeee ffff

'; const ONE_LINE = '

short

'; test("swapping children re-measures without a resize", async () => { const el = mount(TWO_LINES); expect(bars(el)).toHaveLength(2); + await settle(); el.replaceChildren(fragment(ONE_LINE)); await expect.poll(() => bars(el).length).toBe(1); expect(el.hasAttribute("data-bones-measured")).toBe(true); @@ -45,12 +58,14 @@ test("swapping children re-measures without a resize", async () => { test("editing text re-measures", async () => { const el = mount(TWO_LINES); expect(bars(el)).toHaveLength(2); + await settle(); (el.querySelector("p")!.firstChild as Text).data = "short"; await expect.poll(() => bars(el).length).toBe(1); }); test("an emptied subtree deactivates to the CSS path", async () => { const el = mount(TWO_LINES); + await settle(); el.replaceChildren(); await expect.poll(() => el.hasAttribute("data-bones-measured")).toBe(false); expect(bars(el)).toHaveLength(0); @@ -61,18 +76,22 @@ test("bar rendering does not observe itself", async () => { // never reports them. If it did, this would loop: each re-measure replaces // the bar elements, which would re-trigger the observer forever. const el = mount(TWO_LINES); + await settle(); el.replaceChildren(fragment(ONE_LINE)); await expect.poll(() => bars(el).length).toBe(1); - // ResizeObserver guarantees one callback the first time a target is - // observed, even with no actual size change (there is no previously - // reported size to compare against) — see #observe in overlay.ts. That - // fires once here too, harmlessly re-rendering the same content. It is - // bounded, not a loop: settle past it (confirmed by measurement to land - // within a couple of frames) before taking the identity baseline, so this - // assertion isolates the thing it's actually testing — no *further* - // self-triggered churn — from that unrelated, one-time delivery. - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const bar = bars(el)[0]; - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + await settle(); + expect(bars(el)[0]).toBe(bar); +}); + +test("a class change does not re-measure", async () => { + // Attribute mutations are deliberately excluded from the observer config: + // a class or style tick anywhere in the boundary must not touch the bars. + const el = mount(TWO_LINES); + await settle(); + const bar = bars(el)[0]; + el.querySelector("p")!.className = "x"; + await settle(); + expect(bars(el)).toHaveLength(2); expect(bars(el)[0]).toBe(bar); });