+ ,
+ );
+ expect(el.getAttribute("aria-busy")).toBeNull();
+ expect(onHide).toHaveBeenCalledTimes(1);
+ });
+
+ test("minDuration reaches the element as the min-duration attribute", () => {
+ const { container } = render(x);
+ const el = container.querySelector("bones-boundary")!;
+ expect(el.getAttribute("min-duration")).toBe("25");
+ expect(el.minDuration).toBe(25);
+ });
+});
From c5d7e5473ab7455fa80f2c9506a26fab45b0c733 Mon Sep 17 00:00:00 2001
From: Hunter Garrett
Date: Sat, 22 Aug 2026 13:24:08 -0400
Subject: [PATCH 07/12] docs: document bones-boundary and add its sandbox page
---
.changeset/bones-boundary-element.md | 5 +
README.md | 30 ++++-
apps/docs/content/docs/api/bones-boundary.mdx | 119 ++++++++++++++++++
apps/docs/content/docs/api/meta.json | 2 +-
packages/bones/README.md | 30 ++++-
packages/bones/sandbox/boundary.html | 86 +++++++++++++
6 files changed, 259 insertions(+), 13 deletions(-)
create mode 100644 .changeset/bones-boundary-element.md
create mode 100644 apps/docs/content/docs/api/bones-boundary.mdx
create mode 100644 packages/bones/sandbox/boundary.html
diff --git a/.changeset/bones-boundary-element.md b/.changeset/bones-boundary-element.md
new file mode 100644
index 0000000..530ec2c
--- /dev/null
+++ b/.changeset/bones-boundary-element.md
@@ -0,0 +1,5 @@
+---
+"@camp.dev/bones": minor
+---
+
+Add `@camp.dev/bones/element`, a `` custom element that owns the loading state of its subtree. Set `busy` and the element sets `aria-busy="true"` and `inert` after a `delay` (200 ms), keeps them for at least `min-duration` (400 ms), and removes them inside a view transition where the browser supports one. It fires `bones:show` and `bones:hide`, honors `force` for demos, and adopts server-rendered `aria-busy` on upgrade. `@camp.dev/bones/react` gains a hook-free `` wrapper.
diff --git a/README.md b/README.md
index 62da87b..8b15826 100644
--- a/README.md
+++ b/README.md
@@ -32,12 +32,13 @@ import "@camp.dev/bones/css";
## Entry points
-| Import | Contents |
-| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
-| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, ``, `` |
-| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
-| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
-| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |
+| Import | Contents |
+| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
+| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, ``, `` |
+| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
+| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
+| `@camp.dev/bones/element` | ``, a custom element that sets `aria-busy` and `inert` on its subtree with `delay`, `min-duration`, and a crossfade. |
+| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |
React is an optional peer dependency: installing the package without React is supported and only the `/react` entry requires it.
@@ -129,6 +130,23 @@ Set `aria-busy="true"` on the loading region and every unmarked leaf inside it b
Auto rules live in `@layer bones-auto`, so any page CSS that sets `color` on an element outranks the bone's transparent text, and that text stays visible over its skeleton bar. `data-bone-animate` also has to sit on an ancestor of the `aria-busy` element — set directly on it, it has no effect. The `data-bone-animate` overrides rely on `@scope`. In a browser without `@scope`, auto bones always shimmer, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies.
+## Without React
+
+`` manages the loading state for any stack. Set `busy` when a request starts and clear it when the response lands. The element waits 200 ms before showing bones and keeps them for at least 400 ms, then crossfades to content with the View Transitions API where available.
+
+```html
+
+
+
+
Title
+
Body copy.
+
+```
+
+Pair it with `auto.css` for zero-markup skeletons, or with `data-bone` markup from `boneAttributes`. The element is also exported for React as `` from `@camp.dev/bones/react`.
+
## Development
```bash
diff --git a/apps/docs/content/docs/api/bones-boundary.mdx b/apps/docs/content/docs/api/bones-boundary.mdx
new file mode 100644
index 0000000..fb6911a
--- /dev/null
+++ b/apps/docs/content/docs/api/bones-boundary.mdx
@@ -0,0 +1,119 @@
+---
+title: bones-boundary
+description: A custom element that manages the loading state of its subtree.
+---
+
+```ts
+import "@camp.dev/bones/element";
+```
+
+`` sets `aria-busy="true"` and `inert` on itself while its subtree is loading, and removes them when it is not. It draws nothing. `@camp.dev/bones/css` styles `data-bone` markup and `@camp.dev/bones/auto.css` styles unmarked leaves, and both key on `aria-busy="true"`.
+
+Set `busy` when loading starts and remove it when loading ends. The element waits `delay` before it shows bones, so a fast response never flashes a skeleton, and it keeps bones for at least `min-duration`, so a slow one never strobes.
+
+## Usage
+
+```html
+
+
+
+
+
Loading profile
+
Three lines of copy become three bones while busy.
+
+
+
+```
+
+## Attributes
+
+| Attribute | Property | Type | Default | Description |
+| -------------- | ------------- | -------------------- | -------- | ------------------------------------------------------------------ |
+| `busy` | `busy` | boolean | absent | The subtree is loading. |
+| `force` | `force` | boolean | absent | Show bones now and keep them until removed. Skips both timers. |
+| `delay` | `delay` | number (ms) | `200` | How long `busy` must stay set before bones show. |
+| `min-duration` | `minDuration` | number (ms) | `400` | Once shown, bones stay at least this long. |
+| `transition` | `transition` | `"auto"` or `"none"` | `"auto"` | Whether hiding bones runs inside `document.startViewTransition()`. |
+
+Every property reflects to its attribute. A `delay` or `min-duration` that is missing, negative, or not a number falls back to the default. The read-only `showing` property is `true` while bones are visible.
+
+## Output attributes
+
+While bones show, the element carries `aria-busy="true"` and `inert`. `inert` removes the subtree from the tab order and from assistive technology, and ignores pointer events. Do not set these two attributes yourself on the client. Setting them on the server is how you render the loading state before JavaScript runs (see below).
+
+## Events
+
+| Event | When |
+| ------------ | --------------------------------------------------------------- |
+| `bones:show` | After `aria-busy` and `inert` are set. |
+| `bones:hide` | After they are removed. A view transition may still be playing. |
+
+Both bubble and cross shadow roots. Neither is cancelable.
+
+```js
+boundary.addEventListener("bones:hide", () => console.log("content visible"));
+```
+
+## The swap
+
+When `busy` is removed and `min-duration` has elapsed, the element removes its output attributes inside `document.startViewTransition()`, which crossfades bones into content. The call is skipped when `transition="none"` is set, when the browser has no `startViewTransition`, or when `prefers-reduced-motion: reduce` matches. In every case the content appears.
+
+Two limits of the View Transitions API apply:
+
+- The API snapshots the whole document. If two boundaries hide within the same crossfade, the browser skips the first transition and runs the second.
+- The page does not respond to input for the length of the crossfade. Set `transition="none"` on boundaries where that matters.
+
+## Server rendering
+
+Render the output attributes yourself when you know a region is loading at request time:
+
+```html
+…
+```
+
+The stylesheet paints bones and the subtree is inert before the element upgrades. On upgrade the element adopts the showing state, and `min-duration` counts from that moment.
+
+## React
+
+`@camp.dev/bones/react` exports a typed wrapper. Import the element once in a client entry so the tag is registered.
+
+```tsx
+import "@camp.dev/bones/element";
+import { BonesBoundary } from "@camp.dev/bones/react";
+
+function Profile({ loading, user }: { loading: boolean; user?: User }) {
+ return (
+ track("profile:visible")}>
+
{user?.name ?? "Loading"}
+
{user?.bio ?? "Three lines of copy become three bones while busy."}
+
+ );
+}
+```
+
+| Prop | Maps to | Notes |
+| ------------- | -------------- | ------------------------------------------------------------------------------------ |
+| `busy` | `busy` | |
+| `force` | `force` | Also renders `aria-busy="true"` and `inert` on the server. |
+| `delay` | `delay` | |
+| `minDuration` | `min-duration` | |
+| `transition` | `transition` | |
+| `onShow` | `bones:show` | Listener. Passing a function from a server component is an error, as with `onClick`. |
+| `onHide` | `bones:hide` | Same. |
+
+The wrapper uses no hooks, so it renders inside server components. `` and Suspense do not need it: the Suspense fallback renders `data-bone` markup that the stylesheet styles on its own.
+
+## Browser support
+
+Custom elements, `inert` (Chrome 102, Safari 15.5, Firefox 112), and the `:has()` selector that `auto.css` already needs. View Transitions are detected at runtime.
diff --git a/apps/docs/content/docs/api/meta.json b/apps/docs/content/docs/api/meta.json
index 7bcf1d8..1ade7af 100644
--- a/apps/docs/content/docs/api/meta.json
+++ b/apps/docs/content/docs/api/meta.json
@@ -1,4 +1,4 @@
{
"title": "API Reference",
- "pages": ["create-bones", "bones-component", "bones-force", "force-bones"]
+ "pages": ["create-bones", "bones-component", "bones-force", "force-bones", "bones-boundary"]
}
diff --git a/packages/bones/README.md b/packages/bones/README.md
index 64b4a73..7c08650 100644
--- a/packages/bones/README.md
+++ b/packages/bones/README.md
@@ -32,12 +32,13 @@ import "@camp.dev/bones/css";
## Entry points
-| Import | Contents |
-| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
-| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, ``, `` |
-| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
-| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
-| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |
+| Import | Contents |
+| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
+| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, ``, `` |
+| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
+| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
+| `@camp.dev/bones/element` | ``, a custom element that sets `aria-busy` and `inert` on its subtree with `delay`, `min-duration`, and a crossfade. |
+| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |
React is an optional peer dependency: installing the package without React is supported and only the `/react` entry requires it.
@@ -129,6 +130,23 @@ Set `aria-busy="true"` on the loading region and every unmarked leaf inside it b
Auto rules live in `@layer bones-auto`, so any page CSS that sets `color` on an element outranks the bone's transparent text, and that text stays visible over its skeleton bar. `data-bone-animate` also has to sit on an ancestor of the `aria-busy` element — set directly on it, it has no effect. The `data-bone-animate` overrides rely on `@scope`. In a browser without `@scope`, auto bones always shimmer, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies.
+## Without React
+
+`` manages the loading state for any stack. Set `busy` when a request starts and clear it when the response lands. The element waits 200 ms before showing bones and keeps them for at least 400 ms, then crossfades to content with the View Transitions API where available.
+
+```html
+
+
+
+
Title
+
Body copy.
+
+```
+
+Pair it with `auto.css` for zero-markup skeletons, or with `data-bone` markup from `boneAttributes`. The element is also exported for React as `` from `@camp.dev/bones/react`.
+
## Development
```bash
diff --git a/packages/bones/sandbox/boundary.html b/packages/bones/sandbox/boundary.html
new file mode 100644
index 0000000..9859998
--- /dev/null
+++ b/packages/bones/sandbox/boundary.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+ bones-boundary sandbox
+
+
+
+
+
+
+
+
+
+
Article title
+
First paragraph with a couple of lines of copy to show the bar geometry.
+
Second paragraph, shorter.
+
+
+
+
+
+
+
+
+
From ce3538a69432a748075f74841b419114d2beb4ce Mon Sep 17 00:00:00 2001
From: Hunter Garrett
Date: Sat, 22 Aug 2026 14:14:12 -0400
Subject: [PATCH 08/12] fix: defend bones-boundary output attributes and add
the hiding state
React 19 owns any prop it renders. A that goes from `force`
to `busy` makes React delete the aria-busy and inert it wrote for `force`,
and the element had no way to notice: it ended up showing with no skeleton
and an interactive subtree. The element now observes both output attributes
and writes them back while it is showing or draining. It leaves them alone
when it is idle, and its own hide is not something it fights.
A view transition runs its update callback a frame or more later, so #hide
setting the state to idle up front left a window where busy or force could
show bones again and then have the queued update strip them anyway. #hide now
moves to a fifth state, "hiding", and the update bails unless the element is
still in it. busy and force during that window go back to showing with no
second bones:show, and an element removed during it fires nothing.
Two smaller fixes ride along. connectedCallback replays own properties left
behind by a script that ran before the module loaded, so `boundary.busy` set
early no longer shadows the accessor for good. A tag name that is already
taken now warns instead of registering nothing in silence.
---
packages/bones/src/element/boundary.ts | 122 +++++++++++++++++++-----
packages/bones/src/element/index.ts | 11 ++-
packages/bones/tests/boundary.test.ts | 125 +++++++++++++++++++++++++
3 files changed, 234 insertions(+), 24 deletions(-)
diff --git a/packages/bones/src/element/boundary.ts b/packages/bones/src/element/boundary.ts
index ac01a68..2c839b4 100644
--- a/packages/bones/src/element/boundary.ts
+++ b/packages/bones/src/element/boundary.ts
@@ -11,7 +11,14 @@
export const DEFAULT_DELAY = 200;
export const DEFAULT_MIN_DURATION = 400;
-type State = "idle" | "pending" | "showing" | "draining";
+const UPGRADE_PROPERTIES = ["busy", "force", "delay", "minDuration", "transition"] as const;
+type UpgradeProperty = (typeof UPGRADE_PROPERTIES)[number];
+
+// "hiding" is the window a view transition leaves open: #hide has decided to
+// remove the output attributes, but the browser runs the update callback on a
+// later frame. Bones are still on screen, and busy or force arriving in that
+// window must cancel the hide rather than race it.
+type State = "idle" | "pending" | "showing" | "draining" | "hiding";
function parseMs(value: string | null, fallback: number): number {
if (value === null || value.trim() === "") return fallback;
@@ -27,12 +34,15 @@ const Base: typeof HTMLElement =
typeof HTMLElement === "undefined" ? (class {} as unknown as typeof HTMLElement) : HTMLElement;
export class BonesBoundary extends Base {
- static readonly observedAttributes = ["busy", "force"];
+ // aria-busy and inert are outputs, observed only so the element can put
+ // them back when something else strips them. See attributeChangedCallback.
+ static readonly observedAttributes = ["busy", "force", "aria-busy", "inert"];
#state: State = "idle";
#timer: ReturnType | undefined;
#connected = false;
#shownAt = 0;
+ #writingOutput = false;
get busy(): boolean {
return this.hasAttribute("busy");
@@ -78,10 +88,11 @@ export class BonesBoundary extends Base {
}
get showing(): boolean {
- return this.#state === "showing" || this.#state === "draining";
+ return this.#state === "showing" || this.#state === "draining" || this.#state === "hiding";
}
connectedCallback(): void {
+ for (const name of UPGRADE_PROPERTIES) this.#upgradeProperty(name);
this.#connected = true;
// Server-rendered markup can carry aria-busy="true" so the CSS paints
// bones before this script runs. Adopt that as "showing" rather than
@@ -104,14 +115,49 @@ export class BonesBoundary extends Base {
// its state (and its aria-busy/inert attributes) across the move: it
// already has a live skeleton on screen and must keep its original
// min-duration clock rather than re-fire bones:show and restart the
- // window from the reconnect time.
- if (this.#state === "pending") this.#state = "idle";
+ // window from the reconnect time. A hiding element resets too, so the
+ // view transition's queued update finds a state it no longer owns and
+ // bails: a removed element fires no events.
+ if (this.#state === "pending" || this.#state === "hiding") this.#state = "idle";
}
- attributeChangedCallback(): void {
+ attributeChangedCallback(name: string): void {
// Attributes parsed from markup fire this before connectedCallback; the
// connect path evaluates once for all of them.
- if (this.#connected) this.#evaluate();
+ if (!this.#connected) return;
+ if (name === "aria-busy" || name === "inert") {
+ this.#defendOutput();
+ return;
+ }
+ this.#evaluate();
+ }
+
+ // React 19 owns every prop it renders, so a that drops
+ // `force` makes React delete the aria-busy and inert it wrote alongside it,
+ // mid-update, while the element still means to show bones. Put them back.
+ // Only in the two states that mean "bones are on screen and staying there":
+ // #hide leaves "showing" and "draining" for "hiding" before it removes
+ // anything, so the element never fights its own hide, and #writingOutput
+ // keeps the reaction from its own setAttribute out of here entirely. An
+ // idle element leaves both attributes alone; they are the author's then.
+ #defendOutput(): void {
+ if (this.#writingOutput) return;
+ if (this.#state !== "showing" && this.#state !== "draining") return;
+ if (this.getAttribute("aria-busy") === "true" && this.hasAttribute("inert")) return;
+ this.#writeOutput(true);
+ }
+
+ // A page that assigns boundary.busy = false before this module loads writes
+ // an own data property onto the element. Upgrading does not remove it, and
+ // it shadows the prototype accessor from then on, so the setter never runs
+ // and the element never reads the attribute again. Replay the value through
+ // the accessor and delete the shadow.
+ #upgradeProperty(name: UpgradeProperty): void {
+ if (!Object.prototype.hasOwnProperty.call(this, name)) return;
+ const self = this as unknown as Partial>;
+ const value = self[name];
+ delete self[name];
+ self[name] = value;
}
#clearTimer(): void {
@@ -121,9 +167,8 @@ export class BonesBoundary extends Base {
#evaluate(): void {
if (this.force) {
- if (this.#state === "draining") {
- this.#clearTimer();
- this.#state = "showing";
+ if (this.#state === "draining" || this.#state === "hiding") {
+ this.#resume();
} else if (this.#state !== "showing") {
this.#show();
}
@@ -139,9 +184,8 @@ export class BonesBoundary extends Base {
this.#state = "pending";
this.#timer = setTimeout(() => this.#show(), delay);
}
- } else if (this.#state === "draining") {
- this.#clearTimer();
- this.#state = "showing";
+ } else if (this.#state === "draining" || this.#state === "hiding") {
+ this.#resume();
}
return;
}
@@ -149,10 +193,11 @@ export class BonesBoundary extends Base {
if (this.#state === "pending") {
this.#clearTimer();
this.#state = "idle";
- } else if (this.showing) {
- // Covers both "showing" and "draining": a reconnected draining element
- // (its timer was cleared by disconnectedCallback) reschedules the
- // remainder of its original min-duration window rather than a fresh one.
+ } else if (this.#state === "showing" || this.#state === "draining") {
+ // A reconnected draining element (its timer was cleared by
+ // disconnectedCallback) reschedules the remainder of its original
+ // min-duration window rather than a fresh one. "hiding" is left out of
+ // this branch on purpose: that hide is already in flight.
this.#clearTimer();
const remaining = this.#shownAt + this.minDuration - Date.now();
if (remaining <= 0) {
@@ -164,21 +209,36 @@ export class BonesBoundary extends Base {
}
}
+ // Bones never left the screen, so this is not a fresh show: no bones:show,
+ // and shownAt keeps its original value so min-duration still counts from
+ // the first show. The output attributes are written again because whatever
+ // stripped them (React, see attributeChangedCallback) can have run while
+ // the state was "hiding", where the element does not defend them.
+ #resume(): void {
+ this.#clearTimer();
+ this.#state = "showing";
+ this.#writeOutput(true);
+ }
+
#show(): void {
this.#clearTimer();
this.#state = "showing";
this.#shownAt = Date.now();
- this.setAttribute("aria-busy", "true");
- this.toggleAttribute("inert", true);
+ this.#writeOutput(true);
this.dispatchEvent(new CustomEvent("bones:show", { bubbles: true, composed: true }));
}
#hide(): void {
this.#clearTimer();
- this.#state = "idle";
+ this.#state = "hiding";
const update = (): void => {
- this.removeAttribute("aria-busy");
- this.removeAttribute("inert");
+ // A real startViewTransition runs this a frame or more later. Anything
+ // that happened in between (busy set again, the element removed) has
+ // already moved the state off "hiding", and this stale update must not
+ // undo it.
+ if (this.#state !== "hiding") return;
+ this.#state = "idle";
+ this.#writeOutput(false);
this.dispatchEvent(new CustomEvent("bones:hide", { bubbles: true, composed: true }));
};
if (this.#canTransition()) {
@@ -188,6 +248,24 @@ export class BonesBoundary extends Base {
}
}
+ // The element's own writes to its output attributes, flagged while they run
+ // so the attributeChangedCallback that defends those attributes ignores
+ // them and cannot recurse into itself.
+ #writeOutput(shown: boolean): void {
+ this.#writingOutput = true;
+ try {
+ if (shown) {
+ this.setAttribute("aria-busy", "true");
+ this.toggleAttribute("inert", true);
+ } else {
+ this.removeAttribute("aria-busy");
+ this.removeAttribute("inert");
+ }
+ } finally {
+ this.#writingOutput = false;
+ }
+ }
+
// Showing never animates: a crossfade into a skeleton only delays the
// loading state. Hiding animates when the page allows it.
#canTransition(): boolean {
diff --git a/packages/bones/src/element/index.ts b/packages/bones/src/element/index.ts
index ded6410..eb083ff 100644
--- a/packages/bones/src/element/index.ts
+++ b/packages/bones/src/element/index.ts
@@ -17,8 +17,15 @@ declare global {
//
// `import "@camp.dev/bones/element"` is the whole setup. The guard keeps the
// module importable in Node (no customElements) and safe to import twice.
+// A tag that is already taken means two copies of this package on the page,
+// or someone else's element under the same name, and the boundaries in the
+// losing copy silently do nothing. That is worth a line in the console.
// ---------------------------------------------------------------------------
-if (typeof customElements !== "undefined" && !customElements.get("bones-boundary")) {
- customElements.define("bones-boundary", BonesBoundary);
+if (typeof customElements !== "undefined") {
+ if (customElements.get("bones-boundary")) {
+ console.warn("bones-boundary is already defined; @camp.dev/bones/element did not register");
+ } else {
+ customElements.define("bones-boundary", BonesBoundary);
+ }
}
diff --git a/packages/bones/tests/boundary.test.ts b/packages/bones/tests/boundary.test.ts
index 2067b39..7f752bd 100644
--- a/packages/bones/tests/boundary.test.ts
+++ b/packages/bones/tests/boundary.test.ts
@@ -76,6 +76,25 @@ describe("registration and reflection", () => {
expect(el.delay).toBe(200);
});
+ test("a property assigned before upgrade is replayed through the setter", () => {
+ const el = document.createElement("bones-boundary");
+ // What `boundary.busy = true` leaves behind when the page runs it before
+ // the element module loads: an own data property that shadows the
+ // prototype accessor for good once the element upgrades.
+ Object.defineProperty(el, "busy", {
+ value: true,
+ writable: true,
+ configurable: true,
+ enumerable: true,
+ });
+ document.body.append(el);
+ expect(Object.prototype.hasOwnProperty.call(el, "busy")).toBe(false);
+ expect(el.hasAttribute("busy")).toBe(true);
+ expect(el.busy).toBe(true);
+ vi.advanceTimersByTime(200);
+ expect(shown(el)).toBe(true);
+ });
+
test("transition reads auto unless the attribute is none", () => {
const el = mount();
expect(el.transition).toBe("auto");
@@ -205,6 +224,19 @@ describe("force", () => {
expect(shown(el)).toBe(true);
});
+ test("force set during the delay cancels the timer and fires one bones:show", () => {
+ const el = mount();
+ const log = events(el);
+ el.busy = true;
+ vi.advanceTimersByTime(100);
+ el.force = true;
+ expect(shown(el)).toBe(true);
+ // The delay timer would have fired at t=200 and shown a second time.
+ vi.advanceTimersByTime(1000);
+ expect(shown(el)).toBe(true);
+ expect(log).toEqual(["show"]);
+ });
+
test("force set while draining keeps bones showing", () => {
const el = mount();
const log = events(el);
@@ -240,6 +272,46 @@ describe("force", () => {
});
});
+describe("output attribute defense", () => {
+ test("attributes stripped while bones show are put back", () => {
+ const el = mount({ delay: "0" });
+ const log = events(el);
+ el.busy = true;
+ el.removeAttribute("aria-busy");
+ expect(el.getAttribute("aria-busy")).toBe("true");
+ el.removeAttribute("inert");
+ expect(shown(el)).toBe(true);
+ // Draining counts as on screen too: the bones are still painted.
+ el.busy = false;
+ expect(el.showing).toBe(true);
+ el.removeAttribute("aria-busy");
+ el.removeAttribute("inert");
+ expect(shown(el)).toBe(true);
+ vi.advanceTimersByTime(400);
+ expect(shown(el)).toBe(false);
+ expect(log).toEqual(["show", "hide"]);
+ });
+
+ test("the element leaves alone attributes it did not set", () => {
+ const el = mount({ delay: "0", "min-duration": "0" });
+ // Idle: the author owns these attributes, so neither setting nor
+ // removing them by hand pulls the element into a showing state.
+ el.setAttribute("aria-busy", "true");
+ el.toggleAttribute("inert", true);
+ expect(el.showing).toBe(false);
+ el.removeAttribute("aria-busy");
+ el.removeAttribute("inert");
+ expect(el.hasAttribute("aria-busy")).toBe(false);
+ expect(el.hasAttribute("inert")).toBe(false);
+ // And the element's own hide is not something it defends against.
+ el.busy = true;
+ expect(shown(el)).toBe(true);
+ el.busy = false;
+ expect(shown(el)).toBe(false);
+ expect(el.showing).toBe(false);
+ });
+});
+
// ---------------------------------------------------------------------------
// View transitions. jsdom has neither startViewTransition nor matchMedia, so
// each test installs what it needs and the afterEach removes it.
@@ -269,6 +341,18 @@ function stubReducedMotion(matches: boolean) {
(window as MediaWindow).matchMedia = vi.fn(() => ({ matches }));
}
+// A real startViewTransition runs its update callback on a later frame. This
+// stub captures the callback instead of running it, so a test can act inside
+// that window and then let the stale update run.
+function captureTransition(): () => void {
+ let captured = (): void => {};
+ (document as TransitionDoc).startViewTransition = (update: () => void) => {
+ captured = update;
+ return {};
+ };
+ return () => captured();
+}
+
describe("view transitions", () => {
afterEach(() => {
delete (document as TransitionDoc).startViewTransition;
@@ -333,6 +417,47 @@ describe("view transitions", () => {
expect(fn).toHaveBeenCalledTimes(1);
});
+ test("busy set again before the update runs keeps bones and fires no second show", () => {
+ const run = captureTransition();
+ const el = mount({ delay: "0", "min-duration": "0" });
+ const log = events(el);
+ el.busy = true;
+ el.busy = false;
+ expect(el.showing).toBe(true);
+ expect(shown(el)).toBe(true);
+ el.busy = true;
+ run();
+ expect(shown(el)).toBe(true);
+ expect(el.showing).toBe(true);
+ expect(log).toEqual(["show"]);
+ });
+
+ test("an element removed before the update runs fires no bones:hide", () => {
+ const run = captureTransition();
+ const el = mount({ delay: "0", "min-duration": "0" });
+ const log = events(el);
+ el.busy = true;
+ el.busy = false;
+ el.remove();
+ run();
+ expect(log).toEqual(["show"]);
+ expect(el.getAttribute("aria-busy")).toBe("true");
+ expect(el.hasAttribute("inert")).toBe(true);
+ });
+
+ test("an uninterrupted update still hides and fires bones:hide", () => {
+ const run = captureTransition();
+ const el = mount({ delay: "0", "min-duration": "0" });
+ const log = events(el);
+ el.busy = true;
+ el.busy = false;
+ expect(shown(el)).toBe(true);
+ run();
+ expect(shown(el)).toBe(false);
+ expect(el.showing).toBe(false);
+ expect(log).toEqual(["show", "hide"]);
+ });
+
test("hide completes when startViewTransition is absent", () => {
const el = mount({ delay: "0", "min-duration": "0" });
const log = events(el);
From b5af6e6d5428cdc6d83cec0e506df6a0f32ae969 Mon Sep 17 00:00:00 2001
From: Hunter Garrett
Date: Sat, 22 Aug 2026 14:17:01 -0400
Subject: [PATCH 09/12] fix: widen BonesBoundary props and suppress hydration
diffing
BonesBoundaryProps allowed id, className, style, ref, children, and data-*,
and nothing else. `role`, `hidden`, `tabIndex`, `title`, `onClick`, and every
aria-* attribute were type errors on an element whose whole job is to wrap
real content. ElementAttributes now extends HTMLAttributes and
keeps the element's own attributes on top.
The wrapper also sets suppressHydrationWarning. When hydration lands later
than `delay`, which is what selective hydration inside a streaming Suspense
boundary does, the element has already written aria-busy and inert, and React
reports the extra attributes as a mismatch. They are the element's to write,
so React has no business diffing them.
`ref` is now typed as Ref through a type-only import of the
element class. Types are erased at build time, so dist/react/index.mjs still
imports nothing but react.
---
packages/bones/src/react/boundary.ts | 27 +++++++----
packages/bones/tests/boundary-react.test.tsx | 51 ++++++++++++++++++++
2 files changed, 68 insertions(+), 10 deletions(-)
diff --git a/packages/bones/src/react/boundary.ts b/packages/bones/src/react/boundary.ts
index db02e48..2d45d8b 100644
--- a/packages/bones/src/react/boundary.ts
+++ b/packages/bones/src/react/boundary.ts
@@ -1,4 +1,8 @@
-import { createElement, type CSSProperties, type ReactNode, type Ref } from "react";
+import { createElement, type HTMLAttributes, type ReactNode, type Ref } from "react";
+// A type-only import: it is erased at build time, so the rule that src/react
+// never depends on src/element at runtime still holds. The element module is
+// what registers the tag, and this file must not pull it into a bundle.
+import type { BonesBoundary as BonesBoundaryElement } from "../element/boundary.ts";
// ---------------------------------------------------------------------------
// — typed wrapper for the element
@@ -12,22 +16,21 @@ import { createElement, type CSSProperties, type ReactNode, type Ref } from "rea
// `@camp.dev/bones/element` once in a client entry; this file does not do it.
// ---------------------------------------------------------------------------
-type ElementAttributes = {
+// Everything a plain
accepts, plus the element's own attributes. A
+// boundary wraps real content, so `role`, `hidden`, `tabIndex`, `onClick`, and
+// the rest of the aria-* set all have reasons to appear on it.
+interface ElementAttributes extends HTMLAttributes {
busy?: boolean;
force?: boolean;
delay?: number;
"min-duration"?: number;
transition?: "auto" | "none";
- inert?: boolean;
- "aria-busy"?: "true" | "false";
+ // React maps `className` to class on custom elements too, but a raw
+ // written by hand reads better with the HTML name.
class?: string;
- className?: string;
- style?: CSSProperties;
- id?: string;
- children?: ReactNode;
- ref?: Ref;
+ ref?: Ref;
[dataAttribute: `data-${string}`]: string | number | boolean | undefined;
-};
+}
declare module "react" {
namespace JSX {
@@ -67,6 +70,10 @@ export function BonesBoundary({
"min-duration": minDuration,
transition,
...(force ? { "aria-busy": "true", inert: true } : {}),
+ // The element writes aria-busy and inert itself, and it can have done so
+ // before hydration reaches this subtree. React must not treat those
+ // attributes as a mismatch against the server HTML and warn about them.
+ suppressHydrationWarning: true,
"onbones:show": onShow,
"onbones:hide": onHide,
},
diff --git a/packages/bones/tests/boundary-react.test.tsx b/packages/bones/tests/boundary-react.test.tsx
index 07f9718..7a17bd6 100644
--- a/packages/bones/tests/boundary-react.test.tsx
+++ b/packages/bones/tests/boundary-react.test.tsx
@@ -54,6 +54,21 @@ describe("server rendering", () => {
expect(html).toContain('data-testid="b"');
});
+ test("standard HTML and ARIA props type-check and reach the element", () => {
+ const html = renderToString(
+ {}}>
+ x
+ ,
+ );
+ expect(html).toContain('role="status"');
+ expect(html).toContain('aria-label="Loading"');
+ });
+
+ test("suppressHydrationWarning never reaches the markup", () => {
+ const html = renderToString(x);
+ expect(html.toLowerCase()).not.toContain("suppresshydrationwarning");
+ });
+
test("raw type-checks with the augmented intrinsic element", () => {
const html = renderToString(
@@ -92,6 +107,42 @@ describe("client rendering", () => {
expect(onHide).toHaveBeenCalledTimes(1);
});
+ test("the output attributes survive a force to busy rerender", () => {
+ vi.useFakeTimers();
+ const onHide = vi.fn();
+ const { container, rerender } = render(
+
+
copy
+ ,
+ );
+ const el = container.querySelector("bones-boundary")!;
+ expect(el.getAttribute("aria-busy")).toBe("true");
+ expect(el.hasAttribute("inert")).toBe(true);
+ // React deletes the aria-busy and inert it rendered for `force` before it
+ // applies `busy`, so the element has to put them back.
+ rerender(
+
+
+ ,
+ );
+ act(() => {
+ vi.advanceTimersByTime(400);
+ });
+ expect(el.getAttribute("aria-busy")).toBeNull();
+ expect(el.hasAttribute("inert")).toBe(false);
+ expect(el.showing).toBe(false);
+ expect(onHide).toHaveBeenCalledTimes(1);
+ });
+
test("minDuration reaches the element as the min-duration attribute", () => {
const { container } = render(x);
const el = container.querySelector("bones-boundary")!;
From 6177594c92e01b43eff93b9aed5a0ca33efab529 Mon Sep 17 00:00:00 2001
From: Hunter Garrett
Date: Sat, 22 Aug 2026 14:19:48 -0400
Subject: [PATCH 10/12] docs: note bones-boundary upgrade, focus, and hydration
behavior
The usage snippet drove the boundary from a classic inline script, which runs
before the deferred module import above it and touches an element that has
not upgraded. It is a module script now, and it awaits
customElements.whenDefined() before it reads or writes anything.
The rest of the page fills gaps the review found: a new delay or min-duration
applies to the next transition rather than the one running, inert blurs
focus inside the boundary and nothing restores it, bones:show also fires when
an element upgrades with server-rendered aria-busy, React does not own the
attributes the element writes, and a project with its own bones-boundary
declaration in JSX.IntrinsicElements will hit a conflict.
Both READMEs now say that the bare specifier in the "Without React" snippet
needs a bundler or an import map, and where to find the CDN form.
---
README.md | 2 ++
apps/docs/content/docs/api/bones-boundary.mdx | 29 +++++++++++++------
packages/bones/README.md | 2 ++
3 files changed, 24 insertions(+), 9 deletions(-)
diff --git a/README.md b/README.md
index 8b15826..305480c 100644
--- a/README.md
+++ b/README.md
@@ -145,6 +145,8 @@ Auto rules live in `@layer bones-auto`, so any page CSS that sets `color` on an
```
+`@camp.dev/bones/element` is a bare specifier. A browser cannot resolve it on its own, so this snippet needs a bundler or an import map. To load the element straight from a CDN in a plain HTML file, see the URL form on the [bones-boundary docs page](apps/docs/content/docs/api/bones-boundary.mdx).
+
Pair it with `auto.css` for zero-markup skeletons, or with `data-bone` markup from `boneAttributes`. The element is also exported for React as `` from `@camp.dev/bones/react`.
## Development
diff --git a/apps/docs/content/docs/api/bones-boundary.mdx b/apps/docs/content/docs/api/bones-boundary.mdx
index fb6911a..2e6e046 100644
--- a/apps/docs/content/docs/api/bones-boundary.mdx
+++ b/apps/docs/content/docs/api/bones-boundary.mdx
@@ -24,18 +24,19 @@ Set `busy` when loading starts and remove it when loading ends. The element wait
Three lines of copy become three bones while busy.
-
```
+The second script waits for the definition instead of trusting script order. A classic `