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,
|
||||
PropNameConflict,
|
||||
PropValues,
|
||||
RefNameConflict,
|
||||
RefType,
|
||||
Refs,
|
||||
Spec,
|
||||
@ -33,4 +34,5 @@ export type {
|
||||
WebComponentConstructor,
|
||||
WebComponentInstance,
|
||||
WebComponentMembers,
|
||||
WebComponentStatics,
|
||||
} 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>> &
|
||||
WebComponentMembers<S>;
|
||||
|
||||
/** The constructor type {@link WebComponent} returns. */
|
||||
export type WebComponentConstructor<S> = Omit<BaseOf<S>, 'prototype'> & {
|
||||
new (): WebComponentInstance<S>;
|
||||
readonly prototype: WebComponentInstance<S>;
|
||||
/** The static members {@link WebComponent} adds to the generated class. */
|
||||
export interface WebComponentStatics<S> {
|
||||
readonly observedAttributes: readonly ObservedAttributes<S>[];
|
||||
/** Prop key → attribute name: `El.attributeFor.fetch` is `'disable-fetch'`. */
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
||||
/** 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
|
||||
@ -199,30 +204,53 @@ export type AttributesOf<T> = ObservedAttributes<SpecOf<T>>;
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
* collide at runtime; it's reserved so `El.attributeFor` and a prop can't be
|
||||
* confused.
|
||||
* Every name a prop may not use: members of the base instance (`title`,
|
||||
* `hidden`, `id` on `HTMLElement`; `value` on a form-ish base), statics of the
|
||||
* 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. */
|
||||
export type PropNameConflict<K extends PropertyKey> =
|
||||
`Error: prop "${K & string}" conflicts with an existing element member`;
|
||||
|
||||
/**
|
||||
* Intersected with the spec parameter. Any prop whose name already exists on
|
||||
* the base instance (`title`, `hidden`, `id` on `HTMLElement`; `value` on a
|
||||
* form-ish base), on the mixin, or is otherwise reserved, is retyped to {@link PropNameConflict},
|
||||
* so the error lands on that key.
|
||||
* Branded error type: shows up in the message when `element` and `elements`
|
||||
* share a key. An object rather than a string literal because the selector is
|
||||
* itself a string literal, and two string literals intersect to `never`, which
|
||||
* 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> = {
|
||||
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>
|
||||
: 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() } });
|
||||
// @ts-expect-error -- `attributeFor` is reserved for the static lookup
|
||||
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', () => {
|
||||
@ -168,8 +170,31 @@ describe('conflicts', () => {
|
||||
// @ts-expect-error -- `sparkle` is on the base
|
||||
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() } });
|
||||
expectTypeOf(new El().sparkle).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