From 1b3e251a9d9943e21a68150a2d53313315b2a3ab Mon Sep 17 00:00:00 2001
From: Ayo
Date: Mon, 20 Jul 2026 19:57:23 +0200
Subject: [PATCH] feat!: reflect boolean props as bare attributes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Boolean props follow the HTML presence/absence semantics.
- We stick close to standard HTML behavior: flag="false" is treated as
true in most cases. Standard bare attributes disabled and required for
form fields are interpreted as true with non-existence as false.
- The use-cases for aria-*="false" and contenteditable="false" are also
supported by typing them in the JS class as string ("true" | "false").
This "true" | "false" opt-in is types-only. At runtime it's just a
string, and strings already serialize literally and are never removed —
so the enumerated/aria path needs no runtime code.
---
AGENTS.md | 11 ++
README.md | 18 ++-
demo/examples/boolean-props/index.html | 70 ++++++++++++
demo/examples/boolean-props/index.js | 113 +++++++++++++++++++
demo/examples/render-reconciliation/index.js | 9 +-
demo/index.html | 14 +++
docs/src/content/docs/guides/prop-access.mdx | 66 +++++++++++
package.json | 2 +-
src/WebComponent.js | 56 +++++++--
src/utils/create-element.mjs | 5 +
src/utils/deserialize.mjs | 8 +-
test/WebComponent.test.mjs | 72 +++++++++++-
test/e2e/boolean-props-demo.test.mjs | 74 ++++++++++++
test/e2e/boolean-reflection.test.mjs | 80 +++++++++++++
test/utils/create-element.test.mjs | 45 +++++++-
test/utils/deserialize.test.mjs | 14 +--
16 files changed, 618 insertions(+), 39 deletions(-)
create mode 100644 demo/examples/boolean-props/index.html
create mode 100644 demo/examples/boolean-props/index.js
create mode 100644 test/e2e/boolean-props-demo.test.mjs
create mode 100644 test/e2e/boolean-reflection.test.mjs
diff --git a/AGENTS.md b/AGENTS.md
index 9b9abf2..516c7a8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -40,6 +40,17 @@ Everything is in `src/` (entry point `src/index.js` re-exports `WebComponent` an
- **`src/utils/`** — the serialization layer that bridges typed JS values and string attributes: `serialize`/`deserialize` (JSON round-trip for number/boolean/object, passthrough for strings), `get-camel-case`/`get-kebab-case` (attribute ⇄ prop name conversion), `create-element` (turns a vnode tree into real DOM nodes, resolving props to DOM properties/attributes and applying `style` objects — its `applyProp` is the single source of truth for the prop→DOM rule), and `patch` (index-based, non-keyed reconciler used by re-renders; it reuses same-tag elements, applies prop adds/changes/**removals** via `applyProp`, and trims trailing nodes). `src/utils/index.js` re-exports all of them.
+## Definition of done for a behavior change
+
+Any change to observable behavior ships as one unit — code alone is an incomplete change. Land all four together:
+
+1. **Tests** — unit specs in `test/` (or colocated `*.test.mjs`) covering the new contract *and* the old behavior it replaces. Add a `test/e2e/` spec whenever the behavior depends on something happy-dom cannot model faithfully — CSS selector matching, computed styles, custom-element upgrade timing.
+2. **Demo examples** — a runnable example under `demo/examples/` that demonstrates the behavior, linked from a card in `demo/index.html`. Update any existing example the change affects, including ones that now emit a console warning or model a discouraged pattern.
+3. **Documentation** — the guide under `docs/src/content/docs/guides/` that doubles as the behavioral spec. For a breaking change, also update the `README.md` banner with the migration consumers have to perform.
+4. **Size budget** — `pnpm size-limit` stays green. If an addition genuinely needs more headroom, raise the budget in `package.json` deliberately and say so in the change description; never let it drift silently.
+
+Verify with `pnpm test:all` (unit + types + e2e across all engines) before calling the change done.
+
## Testing notes
- Environment is **happy-dom** (set in `vitest.config.mjs`), so real custom-element registration works. Any component under test must be registered with `customElements.define(...)` before instantiation, or the browser throws.
diff --git a/README.md b/README.md
index 4fc2ad5..e033100 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,21 @@
# Web Component Base
-> [!Note]
-> **Quality of Life improvements shipped (v5.1)** — We now have typed props and a [CEM Analyzer Plugin](https://webcomponent.io/cem-plugin/) that makes developer exerience so much better with code editors and other tooling. Composing stylesheets and a fix for boolean properties behavior is included as well. See the [change log](https://github.com/ayo-run/wcb/releases/tag/v5.1.0) for more details. Next up: more improvements to Boolean props and **faster templates!**
+> [!Warning]
+> **Breaking change in v6 — boolean props are now bare attributes.** A boolean
+> prop follows the HTML convention in both directions: **presence means `true`,
+> absence means `false`**. `true` reflects as a bare attribute and `false`
+> removes it, so `toggleAttribute()` and `[attr]` CSS selectors finally work as
+> expected.
+>
+> **Any present value is `true`** — including the literal `flag="false"`, just
+> like native `disabled="false"` is still disabled. If you write boolean
+> attributes as `setAttribute(name, String(bool))`, that now always means
+> `true`; switch those call sites to `toggleAttribute(name, bool)`. wcb warns
+> in the console when it sees a boolean attribute written as `"true"`/`"false"`
+> so the change cannot fail silently. Attributes whose `"false"` is meaningful
+> (`aria-*`, `contenteditable`) should be declared as **string** props.
+>
+> See [Prop Access](https://webcomponent.io/prop-access/) for details.
[](https://www.npmjs.com/package/web-component-base)
[](https://www.npmjs.com/package/web-component-base)
diff --git a/demo/examples/boolean-props/index.html b/demo/examples/boolean-props/index.html
new file mode 100644
index 0000000..c71e733
--- /dev/null
+++ b/demo/examples/boolean-props/index.html
@@ -0,0 +1,70 @@
+
+
+
+
+
+ Boolean props as bare attributes
+
+
+
+
+
+
+
+
Boolean props are bare attributes
+
+ A boolean prop follows the HTML convention in both directions:
+ presence means true, absence means
+ false. Reflecting true sets the bare
+ attribute; reflecting false removes it. Nothing ever gets
+ stamped as flag="false".
+
+
+
Driving the prop
+
+ Every button below reports the resulting prop value and the actual
+ attribute. The border lights up via
+ :host([flag]) — a presence selector that now matches only
+ when the prop is genuinely true.
+
+
+
+
Presence in markup
+
+ All three of these mount with props.flag === true. The last
+ one is the surprise worth internalizing: any present value counts,
+ including the literal string "false", exactly like
+ <input disabled="false"> is still disabled.
+
+
+
+
+
+
+
+
Absent means false
+
+ No attribute, no [flag] match, and
+ props.flag === false. Removing the attribute later also
+ lands on false rather than restoring a declared default —
+ which is why boolean props should default to false and use
+ an inverted name (disabled, not enabled) when
+ you want them on by default.
+
+
+
+
diff --git a/demo/examples/boolean-props/index.js b/demo/examples/boolean-props/index.js
new file mode 100644
index 0000000..6d1c8a4
--- /dev/null
+++ b/demo/examples/boolean-props/index.js
@@ -0,0 +1,113 @@
+import { WebComponent, html } from 'web-component-base'
+
+/**
+ * Boolean props reflect as **bare attributes**: `true` sets the attribute with
+ * no value, `false` removes it entirely — exactly how native `disabled` and
+ * `required` behave.
+ *
+ * Two things that silently broke under the old string reflection now work:
+ *
+ * - `el.toggleAttribute(name, force)` only ever adds/removes an attribute,
+ * it never rewrites a value. With `flag="false"` sitting there, forcing
+ * `true` changed nothing — no attributeChangedCallback, no re-render.
+ * - `[flag]` CSS selectors match on *presence*, so a stamped `flag="false"`
+ * matched too and painted the "on" style onto an element whose prop was
+ * false.
+ */
+class FlagBox extends WebComponent {
+ static props = { flag: false }
+ static shadowRootInit = { mode: 'open' }
+ static styles = `
+ :host {
+ display: block;
+ padding: 0.8em 1em;
+ border: 2px solid #8884;
+ border-radius: 8px;
+ font-family: system-ui, sans-serif;
+ }
+ /* presence selector: matches only while the prop is actually true */
+ :host([flag]) {
+ border-color: #c2410c;
+ background: #c2410c22;
+ }
+ .state { font-weight: 600; }
+ `
+
+ get template() {
+ return html`
+
flag is ${String(this.props.flag)}
+ `
+ }
+}
+customElements.define('flag-box', FlagBox)
+
+/**
+ * The panel wires the three ways host code drives a boolean prop, and prints
+ * what the DOM actually looks like after each one.
+ */
+class BooleanDemo extends WebComponent {
+ static props = { log: '' }
+
+ onInit() {
+ this.box = null
+ }
+
+ afterViewInit() {
+ this.box = this.querySelector('flag-box')
+ this.#report('initial mount')
+ }
+
+ #report(action) {
+ const el = this.box
+ if (!el) return
+ const attr = el.hasAttribute('flag')
+ ? `flag="${el.getAttribute('flag')}"`
+ : '(absent)'
+ this.props.log = `${action} → prop: ${el.props.flag} · attribute: ${attr}`
+ }
+
+ #toggle() {
+ // the platform API — a silent no-op back when flag="false" was stamped
+ this.box.toggleAttribute('flag', !this.box.props.flag)
+ this.#report('toggleAttribute()')
+ }
+
+ #write(value) {
+ this.box.props.flag = value
+ this.#report(`props.flag = ${value}`)
+ }
+
+ #stringly() {
+ // the pre-v6 idiom: any present value is true, so this turns the flag ON
+ // even though it reads as "off". wcb warns in the console when it sees it.
+ this.box.setAttribute('flag', 'false')
+ this.#report('setAttribute("flag", "false")')
+ }
+
+ get template() {
+ return html`
+
+
+
${this.props.log}
+
+
+
+
+
+
+
+
+
+ The last button is the migration trap:
+ any present value is true, so writing the string
+ "false" turns the flag on — just like native
+ disabled="false" is still disabled. Check the console for
+ the warning wcb logs. Use
+ toggleAttribute(name, bool) instead.
+
+ `
+ }
+}
+customElements.define('boolean-demo', BooleanDemo)
diff --git a/demo/examples/render-reconciliation/index.js b/demo/examples/render-reconciliation/index.js
index 60183f5..73bea31 100644
--- a/demo/examples/render-reconciliation/index.js
+++ b/demo/examples/render-reconciliation/index.js
@@ -131,10 +131,13 @@ customElements.define('transition-safe', TransitionSafe)
* attribute is removed, and dropped `style` rules are cleared.
*/
class PropRemoval extends WebComponent {
- static props = { decorated: true }
+ // the demo starts decorated, but the boolean prop still defaults to `false`
+ // and carries the inverted name — absence has to mean both "false" and
+ // "default", which only works when they coincide
+ static props = { plain: false }
get template() {
- const on = this.props.decorated
+ const on = !this.props.plain
// spread so the props are genuinely *absent* when off — that's the removal
// path: present in the old vnode, gone from the new one
const decoration = on
@@ -151,7 +154,7 @@ class PropRemoval extends WebComponent {
style are present only while decorated — toggling removes
them from the same element instead of building a new one.