13 KiB
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 viavitest.config.mjs).pnpm test:watch— vitest in watch mode.- Run a single file / test:
pnpm vitest --run test/WebComponent.test.mjsor filter by name with-t "has a readonly template prop". pnpm lint— ESLint (flat config ineslint.config.mjs, includeseslint-plugin-jsdoc).pnpm format— Prettier over the repo.pnpm build—tscemits.d.tstypes, thencopy:sourceuses esbuild to minify/bundlesrc/*.js+src/utils/*intodist/as ESM.dist/is what gets published;src/is shipped as the readable source.pnpm size-limit— enforces the per-file byte budgets declared inpackage.jsonsize-limit(eachdist/file has its own, e.g.WebComponent.js≤ 2.05 KB,html.js≤ 0.6 KB, each util ≤ 0.5–0.8 KB). Keep additions tiny; this gate is a core project value.size-change-log.mdis 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, sodocs/src/content/docs/guides/library-size.mdmust 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 thedemo/examples/typed-props/example.this.propsis 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:buildbuilds it.pnpm -F storybook dev— run the Storybook testing ground (storybook/) locally;pnpm -F storybook buildbuilds it. Both runcem analyzefirst to regeneratecustom-elements.json. The CEM config imports the plugin from the published subpath (web-component-base/cem-plugin→dist/), so runpnpm buildat 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/:alltarget the other engines (theE2E_BROWSERSenv var, comma-separated, selects instances).pnpm test:allruns unit + types + e2e across all engines.pnpm create wcb(thecreate/workspace package) scaffolds a new component project fromcreate/template/.create/is a separate publishable package, not part of theweb-component-basebundle.
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.observedAttributesis 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).
cloneDefaultsdeep-copies each default withstructuredClone, falling back to by-reference for non-cloneable values (functions, class instances) instead of throwingDataCloneError, and warns once per class about defaults that can't reflect to an attribute (function/symbol) or a discouragedtrueboolean default. Each prop'stypeofis 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): writingthis.props.someProp = xrunstoAttributeand callssetAttribute/removeAttribute(kebab-case), which triggersattributeChangedCallback→render(). That attribute-write-driven cycle is the reactivity model — there is no separate scheduler.#reflectingtracks the attribute currently being written (per name, not a shared flag) so the reflection's ownattributeChangedCallbackdoesn't parse the value back. - The Proxy guards prop types against
#typeMap. A type violation is logged viaconsole.errorand skipped by default, so a stray write can't haltrender()/onChanges; setstatic strictProps = trueto throwTypeErrorinstead. Undeclared props are untyped. attributeChangedCallbackhandles a removed attribute (currentValue === null) by resetting to the declared default — orfalsefor a boolean prop. A removed or empty-string attribute still runsrender()/onChanges(''stays''). Attribute-driven side effects are buffered until afterconnectedCallbackrunsonInit, soonInitalways precedes the first render even when the platform fires attribute callbacks before connect.- Boolean props follow HTML: presence means
true, absence meansfalse.truereflects as a bare attribute,falseremoves it, sotoggleAttribute()and[attr]selectors work; any present value (evenflag="false") istrue, and wcb warns when it sees a boolean attribute written as"true"/"false".toAttribute(name, value)/fromAttribute(name, value)are overridable per-prop converters (callsuperfor the props you don't handle);toAttributereturningnullremoves the attribute. - Lifecycle hooks authors may override:
onInit()(connected),afterViewInit()(after first render),onDestroy()(disconnected),onChanges({property, attribute, previousValue, currentValue})(attribute changed) —propertyis camelCase (matchingpropsaccess) andattributeis the kebab-case name. templateis 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 fromhtml) is diffed against the previous tree withJSON.stringifyand, on change, built viacreateElement+replaceChildrenon the first render and reconciled in place (patchChildren, seesrc/utils/patch.mjs) on every render after that, so focus/caret/uncommitted input values survive. An empty string (andhtml`` , which yieldsundefined) empties the rendered subtree.render()can be called manually or overridden entirely (e.g. to delegate tolit-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 viaadoptedStyleSheets, which only works with a shadow root — setstatic shadowRootInitto opt into shadow DOM.
-
src/html.js— thehtmltagged-template function. It importshtm(thehtm/minibuild, adevDependency) and binds it to a hyperscripth(type, props, children)that produces plain{type, props, children}vnodes.htmis bundled intodist/at build time by esbuild's--bundle, so the published package stays zero-runtime-dependency. License lives invendors/htm/. -
src/cem-plugin.js— a dev-time-only Custom Elements Manifest analyzer plugin that teaches@custom-elements-manifest/analyzerabout wcb'sstatic propsconvention (mapping props to CEM attributes/members and hiding wcb internals). It runs in Node duringcem analyze, is never imported byWebComponent, and is published as theweb-component-base/cem-pluginsubpath (dist/) — which is why Storybook/CEM consumers mustpnpm buildthe 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 applyingstyleobjects — itsapplyPropis the single source of truth for the prop→DOM rule), andpatch(index-based, non-keyed reconciler used by re-renders; it reuses same-tag elements, applies prop adds/changes/removals viaapplyProp, and trims trailing nodes).src/utils/index.jsre-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:
- Tests — unit specs in
test/(or colocated*.test.mjs) covering the new contract and the old behavior it replaces. Add atest/e2e/spec whenever the behavior depends on something happy-dom cannot model faithfully — CSS selector matching, computed styles, custom-element upgrade timing. - Demo examples — a runnable example under
demo/examples/that demonstrates the behavior, linked from a card indemo/index.html. Update any existing example the change affects, including ones that now emit a console warning or model a discouraged pattern. - Documentation — the guide under
docs/src/content/docs/guides/that doubles as the behavioral spec. For a breaking change, also update theREADME.mdbanner with the migration consumers have to perform. - Size budget —
pnpm size-limitstays green. If an addition genuinely needs more headroom, raise the budget inpackage.jsondeliberately 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 insize-change-log.mdand update the headline size indocs/src/content/docs/guides/library-size.mdto 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 withcustomElements.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 tosrc. - 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 (orfalsefor booleans),''stays''. - Prop-type violations log-and-skip unless
static strictProps = true; a bad write never halts rendering. - Defaults are cloned with a
structuredClonefallback, so non-cloneable defaults (functions, class instances) don't throw. onInitalways 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.