From cdfe9281129b8ecf41ab6c8278aa83019f04a8cf Mon Sep 17 00:00:00 2001 From: ayo Date: Tue, 21 Jul 2026 16:13:41 +0200 Subject: [PATCH] docs: document unobserved state via plain class properties --- docs/src/content/docs/api/web-component.md | 6 + docs/src/content/docs/guides/prop-access.mdx | 131 ++++++++++++++++++- 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/docs/src/content/docs/api/web-component.md b/docs/src/content/docs/api/web-component.md index d5ff5de..6d27b57 100644 --- a/docs/src/content/docs/api/web-component.md +++ b/docs/src/content/docs/api/web-component.md @@ -201,6 +201,12 @@ See [Life-cycle Hooks](/life-cycle-hooks/) for worked examples. See it live: [Li Override these to control how one prop crosses the prop/attribute boundary, and call `super` for the props you do not handle. +The default conversion round-trips values through JSON. Types JSON cannot +restore — `Date`, `Map`, `Set`, `URL`, class instances — need overridden +converters to live on `static props`; see +[Custom attribute conversion](/prop-access/#custom-attribute-conversion) for +worked examples, including the non-serializable cases. + ### `toAttribute(name, value)` Converts a prop value into the attribute value that reflects it. diff --git a/docs/src/content/docs/guides/prop-access.mdx b/docs/src/content/docs/guides/prop-access.mdx index b766519..1ab53cd 100644 --- a/docs/src/content/docs/guides/prop-access.mdx +++ b/docs/src/content/docs/guides/prop-access.mdx @@ -55,9 +55,14 @@ class FlagBox extends WebComponent { ``` ```html - - - + + + + + + + + ``` Reflection works the same way in reverse — `true` sets the bare attribute, @@ -134,8 +139,8 @@ class EventCard extends WebComponent { ``` ```html - + ``` Both take the **camelCase prop key**, matching your `static props` declaration @@ -168,6 +173,82 @@ toAttribute(name, value) { `string` and your parsed `Date` is refused. +#### Non-serializable types + +The default converters round-trip prop values through JSON, which restores +numbers, booleans, and plain objects/arrays exactly. Types JSON cannot +represent don't survive the trip: a `Date` comes back as a plain string, and a +`Map` or `Set` collapses to `"{}"` before it ever reaches the attribute. + +Such types are still first-class props — declare a real default of that type +(see the caution above) and override the converters. You can hand-write a +textual form per prop, as `EventCard` above does for its `Date`; when several +props need this, delegate to a serializer built for the job instead: +[devalue](https://github.com/sveltejs/devalue) round-trips `Map`, `Set`, +`Date`, `RegExp`, `BigInt`, `undefined`, and even cyclic references. One +generic pair of overrides then covers every structured prop, present and +future — here is `EventCard` rewritten with it, gaining a `Set` of tags along +the way: + +```js +import { parse, stringify } from 'devalue' + +class EventCard extends WebComponent { + static props = { + when: new Date(0), + tags: new Set(), + title: '', + } + + toAttribute(name, value) { + if (value instanceof Object) return stringify(value) + return super.toAttribute(name, value) + } + + fromAttribute(name, value) { + if (this.constructor.props[name] instanceof Object) return parse(value) + return super.fromAttribute(name, value) + } +} +``` + +The two guards are asymmetric on purpose: `toAttribute` sees the live value, +but `fromAttribute` only ever sees a string — so it consults the declared +default to decide whether the attribute is devalue-encoded. `title` misses +both guards and reflects as a plain string, exactly as before. + +The encoded attribute is not as hand-friendly as a bespoke `when="2026-07-20"`, +but it is still plain text — write it in markup with single quotes, since the +payload contains double quotes: + +```html + + +``` + +devalue is your component's dependency, not wcb's — the base class stays +zero-dependency. + + + +Converters cover any value with a sensible **textual form** — bespoke like an +ISO date, or generic like devalue's encoding. A value with no textual form at +all — a function, an element reference, a live handle like an +`AbortController` — is not a converter problem: even devalue refuses it +(`Cannot stringify a function`), and it doesn't belong in `static props` in +the first place. Keep it as a plain class property instead — see +[Unobserved properties](#unobserved-properties) below. wcb warns once per +class when a declared default is a `function` or `symbol` for exactly this +reason. + ### Typed props in TypeScript See it live: [Compile-time prop types demo ↗](https://demo.webcomponent.io/examples/typed-props/) and [Typed props demo ↗](https://demo.webcomponent.io/examples/type-restore/) @@ -200,6 +281,48 @@ This is types-only — the runtime is unchanged, and `static strictProps` still Values in the defaults object widen as usual, so `variant: 'primary'` types as `string`. To narrow it to a union of allowed values, annotate the defaults: `const props = { variant: 'primary' as 'primary' | 'ghost' }`. +### Unobserved properties + +Everything declared in `static props` is observed and reflected: each key gets +an attribute, writes trigger `render()` and `onChanges()`, and the default +shows up in the DOM on first connect. That is the right contract for a +component's public, DOM-facing API — and unnecessary for internal state. + +For state that doesn't belong in the DOM, use a plain class property. A +`WebComponent` is still just a class extending `HTMLElement`, so ordinary +properties work exactly as on any element and are invisible to the props +machinery — no attribute, no observation, no automatic render: + +```js +class DataTable extends WebComponent { + static props = { compact: false } // public API: + + rows = [] // internal state — never becomes an attribute + #controller = new AbortController() // non-serializable values are fine here + + async onInit() { + const res = await fetch('/rows', { signal: this.#controller.signal }) + this.rows = await res.json() + this.render() // unobserved changes render when you say so + } + + onDestroy() { + this.#controller.abort() + } +} +``` + +Because nothing watches a plain property, call +[`this.render()`](/template-vs-render/) yourself when a change to one should +update the view. + +The rule of thumb: `static props` is for values a consumer sets from markup or +styles against with attribute selectors; a class property — public or +`#private` — is for everything else: large data, functions, and handles like +timers or `AbortController`s that could never round-trip through an attribute +anyway. If you catch yourself thinking "I don't want this showing up as an +attribute", that is the signal it should be a class property, not a prop. + ### Alternatives The current alternatives are using what `HTMLElement` provides out-of-the-box, which are: