Throw on wrong-class [selector, Class] refs instead of skipping them

This commit is contained in:
Buddy 2026-11-06 21:33:42 -08:00
parent b26cff3468
commit 98c209741d
4 changed files with 47 additions and 16 deletions

View File

@ -13,7 +13,7 @@ const SPEC = {
element: {
input: 'input.name', // refs.input: HTMLInputElement | null (from the tag)
thing: '.thing', // refs.thing: Element | null (no tag to go on)
card: ['.card', HTMLElement], // refs.card: HTMLElement | null (checked with instanceof)
card: ['.card', HTMLElement], // refs.card: HTMLElement | null (throws if a match isn't one)
},
elements: { buttons: 'button' }, // refs.buttons: HTMLButtonElement[]
props: {
@ -45,7 +45,7 @@ ExampleElement.observedAttributes; // readonly ('user-id' | 'min' | 'is-ready' |
- **Codecs**: `string`, `number`, `enable`, `disable`, `choice([...])`, `json<T>()`, `prop({ default, decode, encode })`.
- **Shadow DOM**: `shadow` attaches a root and clones `template` into it; `css` is a shared adopted stylesheet. An existing declarative shadow root is used instead (and `shadow`/`template` are ignored). `root` is the shadow root (even when closed) or the element itself.
- **Light DOM**: `template` is appended on first connect; `css` is added once per element type, as a `<style>` in `<head>` or adopted into the enclosing shadow root.
- **Refs**: a selector string is typed from its tag (last compound: `'form > button#go'` → `HTMLButtonElement`), falling back to `Element`. `[selector, Class]` types the ref as `Class` and skips matches that aren't instances of it. A key can't be in both `element` and `elements`.
- **Refs**: a selector string is typed from its tag (last compound: `'form > button#go'` → `HTMLButtonElement`), falling back to `Element`. `[selector, Class]` types the ref as `Class`; reading it throws a `TypeError` if a match isn't an instance of `Class`. A key can't be in both `element` and `elements`.
- **`signal`** aborts on disconnect; a new one is created on each connect. `events` are listened on the host with it.
- **`base`** extends another autonomous element class; prop names that collide with its members (or `HTMLElement`'s, like `title`/`hidden`) are type errors.
- Don't declare a subclass field with a prop's name; it would shadow the reflected accessor.

View File

@ -136,7 +136,7 @@ export type RefType<Sel extends string> =
: Element
: never;
/** A class a ref's matches are checked against with `instanceof`. */
/** A class a ref's matches must be instances of (checked when the ref is read). */
export type ElementClass<T extends Element = Element> = abstract new (...args: never) => T;
/**
@ -145,8 +145,8 @@ export type ElementClass<T extends Element = Element> = abstract new (...args: n
* - `'input.name'` — a selector; the type comes from its tag via {@link RefType}
* (`HTMLInputElement` here, plain `Element` for a tagless `'.thing'`).
* - `['.thing', HTMLElement]` — a selector plus the class its matches must be.
* The ref is typed as that class, and matches that aren't instances of it are
* skipped (`null` for `element`, left out for `elements`).
* The ref is typed as that class, and reading it throws a `TypeError` if a
* match isn't an instance of it.
*/
export type RefSpec = string | readonly [selector: string, type: ElementClass];

View File

@ -244,16 +244,13 @@ export function WebComponent<const S extends Spec>(
// Getter return types are annotated: `PropertyDescriptor.get` returns
// `any`, which the query methods would otherwise infer their result from.
const refs = {};
// With a class given, matches that aren't instances of it are skipped,
// so the typed ref never holds the wrong kind of element.
for (const [name, selector, type] of element) {
Object.defineProperty(refs, name, {
enumerable: true,
get: (): Element | null => {
if (!type) return this.root.querySelector(selector);
return (
[...this.root.querySelectorAll(selector)].find((el) => el instanceof type) ?? null
);
const found = this.root.querySelector(selector);
if (found && type) this.#checkRef(name, selector, type, found);
return found;
},
});
}
@ -262,12 +259,26 @@ export function WebComponent<const S extends Spec>(
enumerable: true,
get: (): Element[] => {
const all = [...this.root.querySelectorAll(selector)];
return type ? all.filter((el) => el instanceof type) : all;
if (type) for (const found of all) this.#checkRef(name, selector, type, found);
return all;
},
});
}
return refs;
}
// A `[selector, Class]` ref is typed as `Class`, so a match of any other
// class is a bug in the template or the spec: fail loudly rather than hand
// back a wrongly typed element.
#checkRef(name: string, selector: string, type: ElementClass, found: Element): void {
// Read before the check: TypeScript narrows `found` to `never` after a
// failed `instanceof`, since `type` is typed as constructing Elements.
const tag = found.localName;
if (found instanceof type) return;
throw new TypeError(
`<${this.localName}> ref "${name}": "${selector}" matched <${tag}>, which is not a ${type.name}`,
);
}
}
// The runtime class can't express the spec-derived accessors and members;

View File

@ -281,18 +281,38 @@ describe('refs', () => {
expect(el.refs.items).toHaveLength(3);
});
test('[selector, Class] refs skip matches of the wrong class', () => {
test('[selector, Class] refs return matches of the right class', () => {
class El extends WebComponent({
shadow: 'open',
template: '<div class="x">div</div><p class="x">p</p><p class="x">p2</p>',
element: { para: ['.x', HTMLParagraphElement], button: ['.x', HTMLButtonElement] },
template: '<p class="x">p</p><p class="x">p2</p>',
element: { para: ['.x', HTMLParagraphElement], missing: ['.nope', HTMLParagraphElement] },
elements: { paras: ['.x', HTMLParagraphElement] },
}) {}
const el = new (define(El))();
expect(el.refs.para?.textContent).toBe('p');
expect(el.refs.button).toBeNull();
expect(el.refs.missing).toBeNull();
expect(el.refs.paras.map((p) => p.textContent)).toEqual(['p', 'p2']);
});
test('[selector, Class] refs throw on a match of the wrong class', () => {
class El extends WebComponent({
shadow: 'open',
template: '<p class="x">p</p><div class="x">div</div>',
element: { button: ['.x', HTMLButtonElement] },
elements: { paras: ['.x', HTMLParagraphElement] },
}) {}
const el = new (define(El))();
expect(() => el.refs.button).toThrow(
new TypeError(
`<${el.localName}> ref "button": ".x" matched <p>, which is not a HTMLButtonElement`,
),
);
expect(() => el.refs.paras).toThrow(
new TypeError(
`<${el.localName}> ref "paras": ".x" matched <div>, which is not a HTMLParagraphElement`,
),
);
});
});
describe('base', () => {