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 97c472f..423e535 100644 --- a/apps/docs/content/docs/api/bones-boundary.mdx +++ b/apps/docs/content/docs/api/bones-boundary.mdx @@ -107,7 +107,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. 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/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..1d85b95 --- /dev/null +++ b/packages/bones/tests/browser/mutation.test.ts @@ -0,0 +1,97 @@ +/// +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. 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(() => { + 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); +} + +// 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); +}); + +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); +}); + +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); + await settle(); + el.replaceChildren(fragment(ONE_LINE)); + await expect.poll(() => bars(el).length).toBe(1); + const bar = bars(el)[0]; + 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); +});