wcb/AGENTS.md
2026-07-23 21:30:26 +02:00

13 KiB
Raw Blame History

AGENTS.md

This file provides guidance to AI coding agents when working with code in this repository.

What this is

web-component-base (WCB) is a zero-dependency, ~1KB base class (WebComponent extends HTMLElement) for building reactive custom elements. Authors subclass it and define only a template and static props; any change to an observed attribute automatically re-renders the component.

Commands

  • pnpm test — run the vitest suite once (coverage is always on via vitest.config.mjs).
  • pnpm test:watch — vitest in watch mode.
  • Run a single file / test: pnpm vitest --run test/WebComponent.test.mjs or filter by name with -t "has a readonly template prop".
  • pnpm lint — ESLint (flat config in eslint.config.mjs, includes eslint-plugin-jsdoc).
  • pnpm format — Prettier over the repo.
  • pnpm buildtsc emits .d.ts types, then copy:source uses esbuild to minify/bundle src/*.js + src/utils/* into dist/ as ESM. dist/ is what gets published; src/ is shipped as the readable source.
  • pnpm size-limit — enforces the per-file byte budgets declared in package.json size-limit (each dist/ file has its own, e.g. WebComponent.js ≤ 2.05 KB, html.js ≤ 0.6 KB, each util ≤ 0.50.8 KB). Keep additions tiny; this gate is a core project value. size-change-log.md is the running record of how each change moved the base-class bundle and why; its latest row's min+brotli figure is the single source of truth for the base-class size, so docs/src/content/docs/guides/library-size.md must be updated in lockstep whenever a new row lands.
  • pnpm test:types — builds, then type-checks the TS type tests (test/types/typed-props.test-d.ts) and the demo/examples/typed-props/ example. this.props is typed by passing the props shape as a class type argument: class X extends WebComponent<typeof props>.
  • pnpm docs — run the Astro docs site (docs/ workspace) locally.
  • pnpm demo — run the Vite examples showcase (demo/ workspace) locally; pnpm demo:build builds it.
  • pnpm -F storybook dev — run the Storybook testing ground (storybook/) locally; pnpm -F storybook build builds it. Both run cem analyze first to regenerate custom-elements.json. The CEM config imports the plugin from the published subpath (web-component-base/cem-plugindist/), so run pnpm build at the root first.
  • pnpm test:e2e — run the browser e2e specs (test/e2e/) via Vitest browser mode (Playwright). Defaults to Chromium; pnpm test:e2e:firefox / :webkit / :all target the other engines (the E2E_BROWSERS env var, comma-separated, selects instances). pnpm test:all runs unit + types + e2e across all engines.
  • pnpm create wcb (the create/ workspace package) scaffolds a new component project from create/template/. create/ is a separate publishable package, not part of the web-component-base bundle.

pnpm is mandatory (a preinstall only-allow pnpm guard enforces it). This is a pnpm workspace with three sub-packages: docs/ (Astro docs site), demo/ (Vite examples showcase, which consumes the root web-component-base package as a workspace:* dependency), and storybook/ (Storybook testing ground — it writes no components of its own, it builds stories over the demo/examples/ components so the CEM plugin is exercised against real ones). The runnable example sources live in demo/examples/ and import the package by name (web-component-base), not via relative src/ paths.

The demo shares one app shell: demo/shell.css (design tokens + component styles, linked by the landing page and every example) and demo/shell.js (defines <app-header> — itself a WebComponent — and prepends it to each example page; prepend never reparents the example's own elements, so their lifecycle is untouched). Every example page links both. Keep new examples consistent by adding <link rel="stylesheet" href="../../shell.css" /> and <script type="module" src="../../shell.js"></script>.

Architecture

Everything is in src/ (entry point src/index.js re-exports WebComponent and html):

  • src/WebComponent.js — the base class and the whole reactivity engine. The flow:

    • static props (a camelCase → default-value map) is the single source of truth. observedAttributes is derived from it by kebab-casing each key.
    • The constructor only clones defaults and sets up the host — it does not touch attributes (the spec forbids attribute mutation in the constructor). cloneDefaults deep-copies each default with structuredClone, falling back to by-reference for non-cloneable values (functions, class instances) instead of throwing DataCloneError, and warns once per class about defaults that can't reflect to an attribute (function/symbol) or a discouraged true boolean default. Each prop's typeof is recorded in a private #typeMap.
    • Defaults are reflected onto attributes in connectedCallback (#reflectDefaults), skipping any attribute already set by markup/SSR so authored values win. The props object is wrapped in a Proxy (#handler): writing this.props.someProp = x runs toAttribute and calls setAttribute/removeAttribute (kebab-case), which triggers attributeChangedCallbackrender(). That attribute-write-driven cycle is the reactivity model — there is no separate scheduler. #reflecting tracks the attribute currently being written (per name, not a shared flag) so the reflection's own attributeChangedCallback doesn't parse the value back.
    • The Proxy guards prop types against #typeMap. A type violation is logged via console.error and skipped by default, so a stray write can't halt render()/onChanges; set static strictProps = true to throw TypeError instead. Undeclared props are untyped.
    • attributeChangedCallback handles a removed attribute (currentValue === null) by resetting to the declared default — or false for a boolean prop. A removed or empty-string attribute still runs render()/onChanges ('' stays ''). Attribute-driven side effects are buffered until after connectedCallback runs onInit, so onInit always precedes the first render even when the platform fires attribute callbacks before connect.
    • Boolean props follow HTML: presence means true, absence means false. true reflects as a bare attribute, false removes it, so toggleAttribute() and [attr] selectors work; any present value (even flag="false") is true, and wcb warns when it sees a boolean attribute written as "true"/"false". toAttribute(name, value) / fromAttribute(name, value) are overridable per-prop converters (call super for the props you don't handle); toAttribute returning null removes the attribute.
    • Lifecycle hooks authors may override: onInit() (connected), afterViewInit() (after first render), onDestroy() (disconnected), onChanges({property, attribute, previousValue, currentValue}) (attribute changed) — property is camelCase (matching props access) and attribute is the kebab-case name.
    • template is a read-only getter (assigning to it throws). render() branches on its type: a string template is written to #host.innerHTML; an object template (a vnode tree from html) is diffed against the previous tree with JSON.stringify and, on change, built via createElement + replaceChildren on the first render and reconciled in place (patchChildren, see src/utils/patch.mjs) on every render after that, so focus/caret/uncommitted input values survive. An empty string (and html `` , which yields undefined) empties the rendered subtree. render() can be called manually or overridden entirely (e.g. to delegate to lit-html).
    • static styles (a string, CSSStyleSheet, or an array of them adopted in declaration order — so a shared tokens sheet can precede component styles) are applied as constructable stylesheets via adoptedStyleSheets, which only works with a shadow root — set static shadowRootInit to opt into shadow DOM.
  • src/html.js — the html tagged-template function. It imports htm (the htm/mini build, a devDependency) and binds it to a hyperscript h(type, props, children) that produces plain {type, props, children} vnodes. htm is bundled into dist/ at build time by esbuild's --bundle, so the published package stays zero-runtime-dependency. License lives in vendors/htm/.

  • src/cem-plugin.js — a dev-time-only Custom Elements Manifest analyzer plugin that teaches @custom-elements-manifest/analyzer about wcb's static props convention (mapping props to CEM attributes/members and hiding wcb internals). It runs in Node during cem analyze, is never imported by WebComponent, and is published as the web-component-base/cem-plugin subpath (dist/) — which is why Storybook/CEM consumers must pnpm build the root first.

  • 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 budgetpnpm 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. Whenever the change moves the base-class bundle, record it as a new row in size-change-log.md and update the headline size in docs/src/content/docs/guides/library-size.md to match that row's new min+brotli figure — the two must never disagree.

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.
  • Tests live both in test/ and colocated next to source as *.test.mjs (e.g. src/utils/serialize.test.mjs). Coverage is restricted to src.
  • happy-dom does not perfectly match browser semantics (notably custom-element upgrade ordering); be skeptical of behaviors that depend on real-browser timing.

State-model invariants (easy to regress)

The v6 state-correctness work has landed — the behaviors described above are the current contract, not aspirational targets. When touching WebComponent.js, preserve these invariants:

  • The constructor never mutates attributes; default reflection happens in connectedCallback (#reflectDefaults), and authored attributes are never overwritten.
  • A removed or empty-string attribute still runs render()/onChanges — removal resets to the declared default (or false for booleans), '' stays ''.
  • Prop-type violations log-and-skip unless static strictProps = true; a bad write never halts rendering.
  • Defaults are cloned with a structuredClone fallback, so non-cloneable defaults (functions, class instances) don't throw.
  • onInit always precedes the first render (attribute-driven side effects are buffered until connect).
  • Boolean props follow HTML presence/absence semantics in both directions.

Docs

The public documentation site is an Astro + Starlight app in docs/ (published to webcomponent.io). Guide content is Markdown/MDX under docs/src/content/docs/guides/; it doubles as the behavioral spec for the library.