Reserve static names for props and reject keys shared by element and elements
This commit is contained in:
parent
6efcdd210e
commit
a398fc98f1
@ -24,6 +24,7 @@ export type {
|
|||||||
PropChange,
|
PropChange,
|
||||||
PropNameConflict,
|
PropNameConflict,
|
||||||
PropValues,
|
PropValues,
|
||||||
|
RefNameConflict,
|
||||||
RefType,
|
RefType,
|
||||||
Refs,
|
Refs,
|
||||||
Spec,
|
Spec,
|
||||||
@ -33,4 +34,5 @@ export type {
|
|||||||
WebComponentConstructor,
|
WebComponentConstructor,
|
||||||
WebComponentInstance,
|
WebComponentInstance,
|
||||||
WebComponentMembers,
|
WebComponentMembers,
|
||||||
|
WebComponentStatics,
|
||||||
} from './types.ts';
|
} from './types.ts';
|
||||||
|
|||||||
62
src/types.ts
62
src/types.ts
@ -176,15 +176,20 @@ export type WebComponentInstance<S> = InstanceType<BaseOf<S>> &
|
|||||||
PropValues<PropsOf<S>> &
|
PropValues<PropsOf<S>> &
|
||||||
WebComponentMembers<S>;
|
WebComponentMembers<S>;
|
||||||
|
|
||||||
/** The constructor type {@link WebComponent} returns. */
|
/** The static members {@link WebComponent} adds to the generated class. */
|
||||||
export type WebComponentConstructor<S> = Omit<BaseOf<S>, 'prototype'> & {
|
export interface WebComponentStatics<S> {
|
||||||
new (): WebComponentInstance<S>;
|
|
||||||
readonly prototype: WebComponentInstance<S>;
|
|
||||||
readonly observedAttributes: readonly ObservedAttributes<S>[];
|
readonly observedAttributes: readonly ObservedAttributes<S>[];
|
||||||
/** Prop key → attribute name: `El.attributeFor.fetch` is `'disable-fetch'`. */
|
/** Prop key → attribute name: `El.attributeFor.fetch` is `'disable-fetch'`. */
|
||||||
readonly attributeFor: AttributeMap<S>;
|
readonly attributeFor: AttributeMap<S>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The constructor type {@link WebComponent} returns. */
|
||||||
|
export type WebComponentConstructor<S> = Omit<BaseOf<S>, 'prototype'> &
|
||||||
|
WebComponentStatics<S> & {
|
||||||
|
new (): WebComponentInstance<S>;
|
||||||
|
readonly prototype: WebComponentInstance<S>;
|
||||||
readonly [spec]?: S;
|
readonly [spec]?: S;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The spec behind a {@link WebComponent} class (or subclass), or `T` itself when it is already a spec. */
|
/** The spec behind a {@link WebComponent} class (or subclass), or `T` itself when it is already a spec. */
|
||||||
export type SpecOf<T> = T extends abstract new (...args: never) => unknown
|
export type SpecOf<T> = T extends abstract new (...args: never) => unknown
|
||||||
@ -199,30 +204,53 @@ export type AttributesOf<T> = ObservedAttributes<SpecOf<T>>;
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Validation
|
// Validation
|
||||||
|
|
||||||
/** Mixin members a prop may not shadow. */
|
|
||||||
type MixinMember = keyof WebComponentMembers<unknown>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every name a prop may not use. `attributeFor` is static, so it wouldn't
|
* Every name a prop may not use: members of the base instance (`title`,
|
||||||
* collide at runtime; it's reserved so `El.attributeFor` and a prop can't be
|
* `hidden`, `id` on `HTMLElement`; `value` on a form-ish base), statics of the
|
||||||
* confused.
|
* base class, and the members and statics the mixin adds. The static names
|
||||||
|
* wouldn't collide at runtime (props live on instances); they're reserved so
|
||||||
|
* `El.attributeFor` / `El.observedAttributes` and a prop can't be confused.
|
||||||
*/
|
*/
|
||||||
type ReservedName = MixinMember | 'attributeFor';
|
type ReservedName<S> =
|
||||||
|
| keyof InstanceType<BaseOf<S>>
|
||||||
|
| keyof BaseOf<S>
|
||||||
|
| keyof WebComponentMembers<unknown>
|
||||||
|
| keyof WebComponentStatics<unknown>;
|
||||||
|
|
||||||
/** Branded error type: shows up in the message when a prop name collides. */
|
/** Branded error type: shows up in the message when a prop name collides. */
|
||||||
export type PropNameConflict<K extends PropertyKey> =
|
export type PropNameConflict<K extends PropertyKey> =
|
||||||
`Error: prop "${K & string}" conflicts with an existing element member`;
|
`Error: prop "${K & string}" conflicts with an existing element member`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intersected with the spec parameter. Any prop whose name already exists on
|
* Branded error type: shows up in the message when `element` and `elements`
|
||||||
* the base instance (`title`, `hidden`, `id` on `HTMLElement`; `value` on a
|
* share a key. An object rather than a string literal because the selector is
|
||||||
* form-ish base), on the mixin, or is otherwise reserved, is retyped to {@link PropNameConflict},
|
* itself a string literal, and two string literals intersect to `never`, which
|
||||||
* so the error lands on that key.
|
* would hide the message.
|
||||||
|
*/
|
||||||
|
export type RefNameConflict<K extends PropertyKey> = {
|
||||||
|
readonly [M in `Error: ref "${K & string}" is in both element and elements`]: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intersected with the spec parameter, so each error lands on the offending
|
||||||
|
* key: a prop with a {@link ReservedName} is retyped to {@link PropNameConflict},
|
||||||
|
* and a key in both `element` and `elements` (both become `refs.<key>`) is
|
||||||
|
* retyped to {@link RefNameConflict} on each side.
|
||||||
*/
|
*/
|
||||||
export type ValidateSpec<S> = {
|
export type ValidateSpec<S> = {
|
||||||
readonly props?: {
|
readonly props?: {
|
||||||
readonly [K in keyof PropsOf<S>]: K extends keyof InstanceType<BaseOf<S>> | ReservedName
|
readonly [K in keyof PropsOf<S>]: K extends ReservedName<S>
|
||||||
? PropNameConflict<K>
|
? PropNameConflict<K>
|
||||||
: PropsOf<S>[K];
|
: PropsOf<S>[K];
|
||||||
};
|
};
|
||||||
|
readonly element?: {
|
||||||
|
readonly [K in keyof ElementRefsOf<S>]: K extends keyof ElementsRefsOf<S>
|
||||||
|
? RefNameConflict<K>
|
||||||
|
: ElementRefsOf<S>[K];
|
||||||
|
};
|
||||||
|
readonly elements?: {
|
||||||
|
readonly [K in keyof ElementsRefsOf<S>]: K extends keyof ElementRefsOf<S>
|
||||||
|
? RefNameConflict<K>
|
||||||
|
: ElementsRefsOf<S>[K];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -159,6 +159,8 @@ describe('conflicts', () => {
|
|||||||
WebComponent({ props: { signal: string() } });
|
WebComponent({ props: { signal: string() } });
|
||||||
// @ts-expect-error -- `attributeFor` is reserved for the static lookup
|
// @ts-expect-error -- `attributeFor` is reserved for the static lookup
|
||||||
WebComponent({ props: { attributeFor: string() } });
|
WebComponent({ props: { attributeFor: string() } });
|
||||||
|
// @ts-expect-error -- `observedAttributes` is a static of the class
|
||||||
|
WebComponent({ props: { observedAttributes: string() } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('conflicts are checked against the base class', () => {
|
test('conflicts are checked against the base class', () => {
|
||||||
@ -168,8 +170,31 @@ describe('conflicts', () => {
|
|||||||
// @ts-expect-error -- `sparkle` is on the base
|
// @ts-expect-error -- `sparkle` is on the base
|
||||||
WebComponent({ base: Fancy, props: { sparkle: enable() } });
|
WebComponent({ base: Fancy, props: { sparkle: enable() } });
|
||||||
|
|
||||||
|
class WithStatic extends HTMLElement {
|
||||||
|
static shine = 1;
|
||||||
|
}
|
||||||
|
// @ts-expect-error -- `shine` is a static on the base
|
||||||
|
WebComponent({ base: WithStatic, props: { shine: enable() } });
|
||||||
|
|
||||||
const El = WebComponent({ base: Fancy, props: { glow: enable() } });
|
const El = WebComponent({ base: Fancy, props: { glow: enable() } });
|
||||||
expectTypeOf(new El().sparkle).toEqualTypeOf<boolean>();
|
expectTypeOf(new El().sparkle).toEqualTypeOf<boolean>();
|
||||||
expectTypeOf(new El().glow).toEqualTypeOf<boolean>();
|
expectTypeOf(new El().glow).toEqualTypeOf<boolean>();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ref conflicts', () => {
|
||||||
|
test('a key in both element and elements is rejected', () => {
|
||||||
|
WebComponent({
|
||||||
|
// @ts-expect-error -- `input` is also in elements
|
||||||
|
element: { input: 'input', button: 'button' },
|
||||||
|
// @ts-expect-error -- `input` is also in element
|
||||||
|
elements: { input: 'textarea', items: 'li' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('distinct keys are fine', () => {
|
||||||
|
const El = WebComponent({ element: { input: 'input' }, elements: { inputs: 'input' } });
|
||||||
|
expectTypeOf(new El().refs.input).toEqualTypeOf<HTMLInputElement | null>();
|
||||||
|
expectTypeOf(new El().refs.inputs).toEqualTypeOf<HTMLInputElement[]>();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user