Skip to content

Add type-system support for custom elements / web components - #1212

Open
NullVoxPopuli-ai-agent wants to merge 6 commits into
typed-ember:mainfrom
NullVoxPopuli-ai-agent:custom-element-types
Open

Add type-system support for custom elements / web components#1212
NullVoxPopuli-ai-agent wants to merge 6 commits into
typed-ember:mainfrom
NullVoxPopuli-ai-agent:custom-element-types

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

Resolves #808. Supersedes #1016 (rebuilt on current main, which has moved ~360 commits since that branch's base).

What this adds

Two global registries in @glint/template, both empty by default and populated by projects via declare global:

import '@glint/template';
import type { MyCustomElement } from './wherever';

declare global {
  interface GlintCustomElementTagNameMap {
    'my-custom-element': MyCustomElement;
  }

  interface GlintCustomElementAttributesMap {
    'my-custom-element': {
      'prop-num': number;
      'prop-str': string;
    };
  }
}
<my-custom-element prop-num={{123}} prop-str="hello"></my-custom-element>
{{! element is typed as MyCustomElement; attributes are checked }}

The registries are independent, and everything unregistered behaves exactly as it does today:

Registration Element type Attributes
element + attributes registered type strictly checked
element only registered type (modifiers, ...attributes, hover) anything accepted
attributes only Element strictly checked
neither (status quo) Element anything accepted

Augmenting the DOM's standard HTMLElementTagNameMap (the Lit convention) also works for the element-type half; GlintCustomElementTagNameMap exists for projects that prefer not to extend the DOM's own registry.

How it works

  • emitElement(...) now returns { name, element, attributes } (previously just { element }), and ElementForTagName falls back to GlintCustomElementTagNameMap before Element.
  • Plain elements resolve attributes by tag name: the transform emits a new applyTagAttributes(__glintY__, { ... }) DSL call whose attrs type comes from the new AttributesForTagName<Name>. This is what lets attributes-only registrations work — there's no element type to reverse-look-up.
  • Components are untouched: they still emit applyAttributes(__glintY__.element, { ... }) and resolve attributes from the signature's Element type, so all existing elementless-component diagnostics/augmentations are byte-for-byte unchanged. AttributesForElement<T> additionally does a reverse lookup into the custom-element registry, so a component with Element: MyCustomElement gets the same attribute checking.
  • AttributesForElement<T> also gains a graceful fallback: an HTMLElement/SVGElement subclass that isn't in any registry now accepts arbitrary attributes. Previously the type-name lookup produced never, which rejected every attribute (including class) for such elements.

Differences from #1016

#1016 was exploratory and internally inconsistent (it carried four different registry names across code/docs/fixtures, and a generated GlintTagNameAttributesMap whose duplicate HTML/SVG keys — a, script, style, title — can't compile). This PR keeps its core architecture (name-keyed registries, name/attributes on the emitElement result, tag-name-based attribute resolution) with these deliberate changes:

  • Two clearly-named registries (GlintCustomElementTagNameMap, GlintCustomElementAttributesMap), both keyed by tag name; no generated tag-name map is needed, so bin/build-elements.mjs and elements.d.ts are untouched.
  • Unregistered tags stay permissive (Record<string, AttrValue>), matching today's behavior, rather than being narrowed to global HTML attributes — no breaking change for existing users of custom elements.
  • Components keep the applyAttributes(__glintY__.element, ...) emit instead of switching everything to applyAttributes(__glintY__, ...), which avoids the nested/uglier "An Element must be specified..." diagnostics that Add support for typed custom-elements and web-components #1016 had to counteract with message-text sniffing in the diagnostic augmentation layer.

Testing

  • New type tests: packages/template/__tests__/custom-elements.test.ts (+ registry in a separate module, proving cross-file registration), private/ElementForTagName.test.ts, additions to attributes.test.ts / AttributesForElement.test.ts.
  • New transform tests + an end-to-end fixture test-packages/ts-template-imports-app/src/custom-elements.gts with @glint-expect-error assertions (verified they're load-bearing: making a flagged line valid fails with "Unused '@ts-expect-error' directive").
  • Full local run: all test:typecheck / test:tsc suites, package-test-core vitest (247 tests), ts-extensionless-app, v2-ts-ember-addon testem, VS Code TS-plugin smoke tests, and pnpm lint all pass.

🤖 Generated with Claude Code

Adds two global registries to @glint/template:

- GlintCustomElementTagNameMap: tag name -> element instance type
  (mirroring HTMLElementTagNameMap), giving registered custom elements
  a real element type for modifiers, ...attributes, and hover info.
- GlintCustomElementAttributesMap: tag name -> accepted attributes,
  checked strictly when registered.

Plain elements now resolve their attributes from the tag name they were
written with (via a new applyTagAttributes DSL function and name/attributes
members on the emitElement result) rather than from the element instance
type, so attributes can be registered for a tag without registering the
element type. Components keep resolving attributes from their signature's
Element type, now with a reverse lookup into the custom element registry
and a graceful fallback for unregistered Element subclasses (previously
the lookup produced 'never', rejecting every attribute).

Resolves typed-ember#808
Supersedes typed-ember#1016

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TypeScript's excess-property check only reports the first unknown key in
an object literal, so emitting all of an element's attributes as a single
applyAttributes/applyTagAttributes call meant that e.g.

    <my-custom-element foo="hi" bar="hi"></my-custom-element>

only flagged 'foo'. Each attribute is now applied in its own call, giving
every attribute its own excess-property check (and its own diagnostic).
This also applies to built-in elements and component attributes, which
had the same first-error-only behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Follow-up pushed: TypeScript's excess-property check only reports the first unknown key per object literal, so <my-custom-element foo="hi" bar="hi"> only flagged foo. Each attribute is now applied in its own applyTagAttributes/applyAttributes call, so every unknown attribute gets its own diagnostic. This also fixes the same latent first-error-only behavior for built-in elements and component attributes.

Hovering an element's tag name now shows its element type, and
go-to-definition resolves to its HTMLElementTagNameMap /
GlintCustomElementTagNameMap entry. This applies to built-in elements
too, which previously had no type information on hover (typed-ember#808).

The transform emits discarded references to a new value-level
elementTypes lookup, with the template's tag name mapped onto them:

    __glintDSL__.noop(__glintDSL__.elementTypes.my_element);
    __glintDSL__.noop(__glintDSL__.elementTypes["my-element"]);

Two forms are needed because the features have conflicting constraints:

- Hover requires a same-length *identifier* (hyphens -> underscores,
  like the each-in -> Globals.each_in keyword aliases): a quoted key's
  quickinfo textSpan includes the quotes, which have no source
  counterpart, so Volar drops the hover result.
- Go-to-definition requires the *raw quoted key*: TypeScript retains no
  declaration links through the key-remapped mapped type backing the
  identifier form.

Each reference's Volar mapping is scoped to just its feature (hover vs
navigation) so the two can't produce duplicate or misanchored results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Also pushed: hovering an element's tag name now shows its element type, and go-to-definition resolves to its HTMLElementTagNameMap / GlintCustomElementTagNameMap registry entry — for built-ins too, which closes out the other half of #808's report (<a> showing no type info).

Implementation note: the transform emits two discarded elementTypes references per element — an identifier form (elementTypes.my_element, hyphens→underscores like the each-inGlobals.each_in keyword aliases) that serves hover, and a raw quoted-key form (elementTypes["my-element"]) that serves navigation. Hover needs the identifier because Volar drops quickinfo whose textSpan (quotes included) can't map back to the template; navigation needs the raw key because TypeScript keeps no declaration links through key-remapped mapped types. Each reference's Volar mapping is capability-scoped so they don't double-report. Covered by new language-server tests (element-tag-hover.test.ts).

Hovering an element's tag name previously surfaced the raw quickinfo of
the discarded elementTypes lookup the transform emits, displayed
property-style: '(property) my_custom_element: MyCustomElement'. The
tsserver plugin now rewrites that quickinfo — when the hovered position
maps to a plain element's tag name and the display has the expected
'(property) <tag-name-ish>: ...' shape — to show only the type:
'MyCustomElement'. Components and every other hover are untouched.

Notes:
- The mapping tree can hold sibling twins of a node where the first twin
  has no children, and narrowestMappingForOriginalRange returns the
  first containing child — so the plugin locates the smallest containing
  mapping itself.
- The property name may appear as an identifier (my_custom_element), a
  quoted key ('foo-bar' or "div"), depending on which elementTypes
  entry served the hover; all forms are matched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Hover polish per feedback: tag-name hover now shows just the element type (MyCustomElement, HTMLDivElement) instead of the property-style (property) my_custom_element: MyCustomElement. Done in the tsserver plugin's getQuickInfoAtPosition proxy — when the hovered position maps to a plain element's tag name, the (property) name: prefix is stripped from the quickinfo displayParts. Components and all other hovers are untouched, and JSDoc on registry entries still comes through.

Hovering (or invoking go-to-definition on) an attribute of a plain
element surfaced the private __glintY__ binding: the applyAttributes /
applyTagAttributes *target argument* is mapped onto the attribute node
(so invalid-element diagnostics anchor there), and that mapping carried
full language-feature capabilities. It is now scoped to verification
only — and captureMapping applies the @glint-ignore/expect-error
directive support to explicitly-provided code features too, so the
diagnostics that mapping anchors remain suppressible.

With the pollution gone, attribute-name hover/definition come from the
object-literal key mapping: definition resolves to the attribute's
declaration (e.g. a GlintCustomElementAttributesMap entry), and hover
works for identifier-safe names. Quoted keys (hyphenated names like
prop-num) still yielded no hover — TypeScript's quickinfo textSpan for
a quoted key includes the quotes, which have no source counterpart, so
Volar drops the result. The tsserver plugin now recovers those: it
locates the generated key via the mapping tree, resolves the contextual
property with the type checker, and synthesizes a property-style
quickinfo with a source-space span.

Note: program positions in TS plugin mode are shifted by the source
text's length relative to transformedContents offsets (Volar's
leading-offset scheme — the target script is source + generated
concatenated); the recovery accounts for that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Attribute-name hover/goto fixed (was surfacing the private __glintY__ type): the applyTagAttributes/applyAttributes target-argument mapping onto the attribute node is now verification-only (it exists purely to anchor invalid-element diagnostics), so it no longer serves hover/navigation/completion. With that pollution gone, go-to-definition on an attribute resolves to its declaration (e.g. your GlintCustomElementAttributesMap entry) and hover shows (property) 'prop-num': number. Hyphenated names needed one extra piece: their quoted object keys get their quickinfo dropped by Volar (textSpan includes the quotes), so the tsserver plugin synthesizes the property quickinfo from the contextual type with a source-space span. Also generalized captureMapping so @glint-expect-error directive support applies to explicitly-provided code features. New tests cover attr hover + goto for both custom and built-in elements.

Editors resolve definitions through tsserver's definitionAndBoundSpan
command (VS Code's TS integration and most LSP clients), not the plain
definition command the earlier tests exercised. Volar's proxy for
definitionAndBoundSpan drops the ENTIRE result when the origin (bound)
span can't be translated back to the template — which is the case for
the quoted elementTypes["my-element"] keys serving element tag names
and for quoted attribute keys, since the quotes have no source
counterpart. So go-to-definition on <my-custom-element> (or a
hyphenated attribute) worked in the harness but returned nothing in
VS Code and Neovim.

The tsserver plugin now falls back when the decorated
definitionAndBoundSpan comes up empty inside a template: it takes the
definitions from getDefinitionAtPosition (whose per-target translation
works) and synthesizes the bound span from the mapping tree (the tag /
attribute name range).

The definition tests now issue definitionAndBoundSpan so they exercise
the same path editors use, and assert the bound span too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Fixed go-to-definition on <my-custom-element> in real editors (VS Code + Neovim reported broken): editors use tsserver's definitionAndBoundSpan command, not the plain definition command the earlier tests exercised — and Volar's definitionAndBoundSpan proxy drops the entire result when the origin ("bound") span can't translate back to the template (our quoted elementTypes[\"my-element\"] key includes quotes with no source counterpart). The tsserver plugin now falls back to getDefinitionAtPosition's definitions and synthesizes the bound span from the mapping tree (the tag/attribute name range). The definition tests were switched to definitionAndBoundSpan so they exercise the same path editors do.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[main] No type information for elements, custom-elements

2 participants