Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/measured-mutation-observer.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion apps/docs/content/docs/api/bones-boundary.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 32 additions & 10 deletions packages/bones/src/element/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
97 changes: 97 additions & 0 deletions packages/bones/tests/browser/mutation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/// <reference types="vite-plus/client" />
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",
`<bones-boundary force precision="measured" transition="none" min-duration="0"
style="width: 20ch; height: 120px; font: 16px/1.5 monospace;">${inner}</bones-boundary>`,
);
return document.querySelector("bones-boundary")!;
}

function bars(el: BonesBoundary): HTMLElement[] {
return Array.from(el.shadowRoot!.querySelectorAll<HTMLElement>('[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<void> {
return new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
}

const TWO_LINES = '<p style="margin: 0">aaaa bbbb cccc dddd eeee ffff</p>';
const ONE_LINE = '<p style="margin: 0">short</p>';

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);
});
Loading