From 1176bdb96c71b4ad585cf7e762046b23a9ead77e Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:40:33 -0500 Subject: [PATCH 1/4] Failing tests for generic component signature that should narrow the element choice based on arg usage --- .../__tests__/generic-component.test.ts | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 packages/template/__tests__/generic-component.test.ts diff --git a/packages/template/__tests__/generic-component.test.ts b/packages/template/__tests__/generic-component.test.ts new file mode 100644 index 000000000..51d836642 --- /dev/null +++ b/packages/template/__tests__/generic-component.test.ts @@ -0,0 +1,141 @@ +import '@glint/ember-tsc/types'; +import Component from '@glimmer/component'; + +import { expectTypeOf } from 'expect-type'; +import { + applyModifier, + emitComponent, + emitElement, + emitContent, + resolve, + resolveOrReturn, + templateForBackingValue, + yieldToBlock, + NamedArgsMarker, + templateExpression, + applyAttributes, +} from '../-private/dsl'; +import TestComponent, { globals } from './test-component'; + +{ + /** + * A Link component that can sometimes be a div, if the href is missing + */ + + type Signature = Args extends { href: string } + ? { + Element: HTMLAnchorElement; + Args: { href: string }; + Blocks: { default: [] }; + } + : { + Element: HTMLDivElement; + Args: { foo?: number }; + Blocks: { default: [] }; + }; + + class Link extends Component> {} + + // Renders as anchor + { + const __glintY__ = emitComponent( + resolve(Link)({ + // Error unexpected + href: 'https://www.auditboard.com/platform/ai', + ...NamedArgsMarker, + }), + ); + + expectTypeOf(__glintY__.element).toEqualTypeOf(); + + // element here "should" be derived from args above, but it is not + applyAttributes(__glintY__.element, { + // error unexpected + target: '_blank', + }); + } + + // Renders as div + { + const __glintY__ = emitComponent( + resolve(Link)({ + // @ts-expect-error: wrong arg type (deliberate) + href: 2, + ...NamedArgsMarker, + }), + ); + + expectTypeOf(__glintY__.element).toEqualTypeOf(); + + // element here "should" be derived from args above, but it is not + applyAttributes(__glintY__.element, { + // @ts-expect-error: target not valid on div + target: '_blank', + }); + } +} + +{ + /** + * An input component that can sometimes be a textarea + */ + + type Signature = Args extends { value: string } + ? { + Element: HTMLInputElement; + Args: { value: string }; + Blocks: { default: [] }; + } + : { + Element: HTMLTextAreaElement; + Args: { html: string }; + Blocks: { default: [] }; + }; + + class SomeInput extends Component> {} + + // Renders as input + { + const __glintY__ = emitComponent( + resolve(SomeInput)({ + // Error unexpected + value: 'test', + ...NamedArgsMarker, + }), + ); + + expectTypeOf(__glintY__.element).toEqualTypeOf(); + + // element here "should" be derived from args above, but it is not + applyAttributes(__glintY__.element, { + // error unexpected + value: 'hello', + // @ts-expect-error: not valid on input + wrap: 'hard', + }); + } + + // Renders as textarea + { + const __glintY__ = emitComponent( + resolve(SomeInput)({ + // Error unexpected + html: 'https://www.auditboard.com/platform/ai', + ...NamedArgsMarker, + }), + ); + + expectTypeOf(__glintY__.element).toEqualTypeOf(); + + // element here "should" be derived from args above, but it is not + applyAttributes(__glintY__.element, { + // error unexpected + value: 'hello', + + // @ts-expect-error not valid on textarea + checked: 'true', + // @ts-expect-error: not valid on textarea + alt: 'an alt tag', + }); + } +} From 6ec6848014d06b3e7e7935349eca749d05757634 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 7 Jan 2026 12:34:57 -0500 Subject: [PATCH 2/4] Add more failing tests --- ...ric-component.test.ts => generics.test.ts} | 80 +++++++++++++++++-- 1 file changed, 72 insertions(+), 8 deletions(-) rename packages/template/__tests__/{generic-component.test.ts => generics.test.ts} (62%) diff --git a/packages/template/__tests__/generic-component.test.ts b/packages/template/__tests__/generics.test.ts similarity index 62% rename from packages/template/__tests__/generic-component.test.ts rename to packages/template/__tests__/generics.test.ts index 51d836642..cb4ee31d5 100644 --- a/packages/template/__tests__/generic-component.test.ts +++ b/packages/template/__tests__/generics.test.ts @@ -1,21 +1,17 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import '@glint/ember-tsc/types'; import Component from '@glimmer/component'; import { expectTypeOf } from 'expect-type'; import { - applyModifier, emitComponent, - emitElement, - emitContent, resolve, - resolveOrReturn, - templateForBackingValue, - yieldToBlock, NamedArgsMarker, - templateExpression, applyAttributes, + emitElement, + applyModifier, } from '../-private/dsl'; -import TestComponent, { globals } from './test-component'; +import { ModifierLike } from '../-private'; { /** @@ -139,3 +135,71 @@ import TestComponent, { globals } from './test-component'; }); } } + +/** + * Modifiers *cannot* choose their element, based on args, + * but the element can choose which args are available + */ +{ + type ImageModifier = Named extends { src: string } + ? { + Element: HTMLImageElement; + Args: { + Named: { src: string; alt?: string }; + }; + } + : { + Element: HTMLCanvasElement; + Args: { + Named: { width: number; height: number }; + }; + }; + + interface DefaultSignature { + Element: Element; + } + + interface BaseClass extends InstanceType> {} + class BaseClass { + constructor(args: T) {} + } + /** + * We have to fake a class modifier, so that we can pass along the + * generic argument, or maybe rather enable TS to be able to infer + */ + class ImageModifierClass extends BaseClass>> {} + + { + const img = emitElement('img'); + const div = emitElement('div'); + const canvas = emitElement('canvas'); + + expectTypeOf(img.element).toEqualTypeOf(); + + applyModifier( + resolve(ImageModifierClass)(img.element, { + // Correct: no error expected because the img element has a src attribute + src: 'bar', + ...NamedArgsMarker, + }), + ); + + applyModifier( + resolve(ImageModifierClass)(canvas.element, { + // Correct: no error expected because the canvas element has width and height attributes + width: 200, + height: 100, + // @ts-expect-error: error expected because canvas element does not have a src attribute + src: 'bar', + ...NamedArgsMarker, + }), + ); + + applyModifier( + resolve(ImageModifierClass)( + // @ts-expect-error: wrong element type, expects image or canvas + div.element, + ), + ); + } +} From 8d1871145002ca300f36f5a4a143db87951c6744 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:36:07 -0500 Subject: [PATCH 3/4] More tests, more exploration --- packages/template/-private/dsl/resolve.d.ts | 17 +- packages/template/__tests__/generics.test.ts | 211 ++++++++++++++++++- 2 files changed, 213 insertions(+), 15 deletions(-) diff --git a/packages/template/-private/dsl/resolve.d.ts b/packages/template/-private/dsl/resolve.d.ts index 8c41df10c..8ec6f334c 100644 --- a/packages/template/-private/dsl/resolve.d.ts +++ b/packages/template/-private/dsl/resolve.d.ts @@ -1,4 +1,4 @@ -import { DirectInvokable, InvokableInstance, Invoke, InvokeDirect } from '../integration'; +import { DirectInvokable, InvokableInstance, Invoke, InvokeDirect, UnwrapNamedArgs } from '../integration'; import { ResolveOrReturn } from './types'; /* @@ -32,9 +32,18 @@ import { ResolveOrReturn } from './types'; */ export declare function resolve(item: T): T[typeof InvokeDirect]; -export declare function resolve( - item: (abstract new (...args: Args) => Instance) | null | undefined, -): (...args: Parameters) => ReturnType; +export declare function resolve< + Args extends any[], + Instance extends InvokableInstance>( + item: (abstract new (...args: Args) => Instance) | null | undefined, + ): + (...args: Parameters[typeof Invoke]>) => ReturnType[typeof Invoke]>; + + +export declare function resolveComponent>( + item: (abstract new (...args: Args) => Instance) | null | undefined, + args: Args +): >(...args: _Args) => ReturnType; /* * A mustache like `{{this.foo}}` might either return a plain value like a string diff --git a/packages/template/__tests__/generics.test.ts b/packages/template/__tests__/generics.test.ts index cb4ee31d5..32de1e3eb 100644 --- a/packages/template/__tests__/generics.test.ts +++ b/packages/template/__tests__/generics.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-inner-declarations */ /* eslint-disable @typescript-eslint/no-unused-vars */ import '@glint/ember-tsc/types'; import Component from '@glimmer/component'; @@ -6,12 +7,23 @@ import { expectTypeOf } from 'expect-type'; import { emitComponent, resolve, + resolveComponent, + resolveModifier, NamedArgsMarker, applyAttributes, emitElement, applyModifier, } from '../-private/dsl'; import { ModifierLike } from '../-private'; +import { + AnyFunction, + ComponentReturn, + Invoke, + Element, + Blocks, + Invokable, + InvokableInstance, +} from '../-private/integration'; { /** @@ -21,27 +33,167 @@ import { ModifierLike } from '../-private'; type Signature = Args extends { href: string } ? { Element: HTMLAnchorElement; - Args: { href: string }; + Args: Args; Blocks: { default: [] }; } : { Element: HTMLDivElement; - Args: { foo?: number }; + Args: { div: true } & Args; Blocks: { default: [] }; }; class Link extends Component> {} + // Manual sanity checking for the above signature + { + type X = ReturnType[typeof Invoke]>; + type Y = X[typeof Element]; + + expectTypeOf().toEqualTypeOf(); + + type A = ReturnType[typeof Invoke]>; + type B = A[typeof Element]; + + expectTypeOf().toEqualTypeOf(); + } + // Manual sanity checking for the above signature + // This proves that args can be associated to the component, + // and that the return value can derive from the args. + // This is the proof of concept for the part of glint/template that supports generics / narrowing. + // { + // type GenericSignature = { Args: unknown, Element: Element }; + // type Sig2 = Args extends { href: string } ? { Args: Args, Element: HTMLAnchorElement } : { Args: Args, Element: HTMLDivElement }; + + // function test(y: Args): Sig['Element'] { + // return 0 as unknown as Sig['Element']; + // } + // interface Link2 extends Sig2 {} + // class Link2 {} + // let n = test({ href: 'test' }); + // expectTypeOf(n).toEqualTypeOf(); + // let m = test({ foo: 123 }); + // expectTypeOf(m).toEqualTypeOf(); + + // function elementOf any>(args: Args, comp: Comp): ComponentReturn { + // return 0 as any; + // } + + // let el = elementOf({ href: 'test'}, Link); + + // expectTypeOf(el[Element]).toEqualTypeOf(); + // } + // Renders as anchor + { + const fn = resolve(Link); + const resolved = fn<{ href: string }>({ + // Error unexpected + href: 'https://www.auditboard.com/platform/ai', + ...NamedArgsMarker, + }); + + expectTypeOf(resolved).not.toBeAny(); + expectTypeOf(resolved).not.toEqualTypeOf(); + expectTypeOf(resolved).not.toBeUnknown(); + + const __glintY__ = emitComponent(resolved); + + expectTypeOf(__glintY__.element).not.toBeAny(); + expectTypeOf(__glintY__.element).toEqualTypeOf(); + + // element here "should" be derived from args above, but it is not + applyAttributes(__glintY__.element, { + // error unexpected + target: '_blank', + }); + } + { + const resolved = resolveComponent(Link, { + // Error unexpected + href: 'https://www.auditboard.com/platform/ai', + ...NamedArgsMarker, + }); + + expectTypeOf(resolved).not.toBeAny(); + expectTypeOf(resolved).not.toEqualTypeOf(); + expectTypeOf(resolved).not.toBeUnknown(); + + const __glintY__ = emitComponent(resolved); + + expectTypeOf(__glintY__.element).not.toBeAny(); + expectTypeOf(__glintY__.element).toEqualTypeOf(); + + // element here "should" be derived from args above, but it is not + applyAttributes(__glintY__.element, { + // error unexpected + target: '_blank', + }); + } + + // Renders as div { const __glintY__ = emitComponent( - resolve(Link)({ - // Error unexpected - href: 'https://www.auditboard.com/platform/ai', + resolveComponent(Link, { + // @ts-expect-error: wrong arg type (deliberate) + href: 2, ...NamedArgsMarker, }), ); + expectTypeOf(__glintY__.element).not.toBeAny(); + expectTypeOf(__glintY__.element).toEqualTypeOf(); + + // element here "should" be derived from args above, but it is not + applyAttributes(__glintY__.element, { + // @ts-expect-error: target not valid on div + target: '_blank', + }); + } +} + +{ + /** + * A Link component that can sometimes be a div, if the href is missing + * but where the condition is on Args, not the whole signature + */ + + interface Signature { + Element: Args extends { href: string } ? HTMLAnchorElement : HTMLDivElement; + Args: Args; + Blocks: { default: [] }; + } + + class Link extends Component> {} + + // Manual sanity checking for the above signature + { + type X = ReturnType[typeof Invoke]>; + type Y = X[typeof Element]; + + expectTypeOf().toEqualTypeOf(); + + type A = ReturnType[typeof Invoke]>; + type B = A[typeof Element]; + + expectTypeOf().toEqualTypeOf(); + } + + // Renders as anchor + { + const fn = resolve(Link); + const resolved = fn({ + // Error unexpected + href: 'https://www.auditboard.com/platform/ai', + ...NamedArgsMarker, + }); + + expectTypeOf(resolved).not.toBeAny(); + expectTypeOf(resolved).not.toEqualTypeOf(); + expectTypeOf(resolved).not.toBeUnknown(); + + const __glintY__ = emitComponent(resolved); + + expectTypeOf(__glintY__.element).not.toBeAny(); expectTypeOf(__glintY__.element).toEqualTypeOf(); // element here "should" be derived from args above, but it is not @@ -54,13 +206,14 @@ import { ModifierLike } from '../-private'; // Renders as div { const __glintY__ = emitComponent( - resolve(Link)({ + resolveComponent(Link, { // @ts-expect-error: wrong arg type (deliberate) href: 2, ...NamedArgsMarker, }), ); + expectTypeOf(__glintY__.element).not.toBeAny(); expectTypeOf(__glintY__.element).toEqualTypeOf(); // element here "should" be derived from args above, but it is not @@ -93,13 +246,14 @@ import { ModifierLike } from '../-private'; // Renders as input { const __glintY__ = emitComponent( - resolve(SomeInput)({ + resolveComponent(SomeInput, { // Error unexpected value: 'test', ...NamedArgsMarker, }), ); + expectTypeOf(__glintY__.element).not.toBeAny(); expectTypeOf(__glintY__.element).toEqualTypeOf(); // element here "should" be derived from args above, but it is not @@ -114,13 +268,14 @@ import { ModifierLike } from '../-private'; // Renders as textarea { const __glintY__ = emitComponent( - resolve(SomeInput)({ + resolveComponent(SomeInput, { // Error unexpected html: 'https://www.auditboard.com/platform/ai', ...NamedArgsMarker, }), ); + expectTypeOf(__glintY__.element).not.toBeAny(); expectTypeOf(__glintY__.element).toEqualTypeOf(); // element here "should" be derived from args above, but it is not @@ -177,7 +332,7 @@ import { ModifierLike } from '../-private'; expectTypeOf(img.element).toEqualTypeOf(); applyModifier( - resolve(ImageModifierClass)(img.element, { + resolveModifier(ImageModifierClass)(img.element, { // Correct: no error expected because the img element has a src attribute src: 'bar', ...NamedArgsMarker, @@ -185,7 +340,7 @@ import { ModifierLike } from '../-private'; ); applyModifier( - resolve(ImageModifierClass)(canvas.element, { + resolveModifier(ImageModifierClass)(canvas.element, { // Correct: no error expected because the canvas element has width and height attributes width: 200, height: 100, @@ -196,10 +351,44 @@ import { ModifierLike } from '../-private'; ); applyModifier( - resolve(ImageModifierClass)( + resolveModifier(ImageModifierClass)( // @ts-expect-error: wrong element type, expects image or canvas div.element, ), ); } } + +/** + * Modifiers with generic args, but static element + */ +{ + interface Area { + area: V; + } + + type PositionalArgs = [area: Area, data: V[]]; + + interface D3AreaSignature { + Element: SVGPathElement; + Args: { + Positional: PositionalArgs; + }; + } + interface DefaultSignature { + Element: Element; + } + interface Modifier extends InstanceType> {} + class Modifier { + constructor(args: T) {} + } + + class D3Area extends Modifier> {} + + let svgPath = emitElement('path'); + + let resolved = resolve(D3Area); + let withElement = resolved(svgPath.element, { area: 42 }, [1, 2, 3]); + + applyModifier(withElement); +} From d83fb2dfab0a4695e4118dda268c5d4c5f99416e Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:20:17 -0500 Subject: [PATCH 4/4] SVG test was invalid, fixed --- packages/template/__tests__/generics.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/template/__tests__/generics.test.ts b/packages/template/__tests__/generics.test.ts index 32de1e3eb..8b0160fef 100644 --- a/packages/template/__tests__/generics.test.ts +++ b/packages/template/__tests__/generics.test.ts @@ -13,6 +13,7 @@ import { applyAttributes, emitElement, applyModifier, + emitSVGElement, } from '../-private/dsl'; import { ModifierLike } from '../-private'; import { @@ -376,7 +377,7 @@ import { }; } interface DefaultSignature { - Element: Element; + Element: 'default boo!'; } interface Modifier extends InstanceType> {} class Modifier { @@ -385,7 +386,13 @@ import { class D3Area extends Modifier> {} - let svgPath = emitElement('path'); + let svgPath = emitSVGElement('path'); + + expectTypeOf(svgPath.element).not.toBeAny(); + expectTypeOf(svgPath.element).not.toBeUnknown(); + expectTypeOf(svgPath.element).not.toEqualTypeOf(); + expectTypeOf(svgPath.element).not.toEqualTypeOf(); + expectTypeOf(svgPath.element).toEqualTypeOf(); let resolved = resolve(D3Area); let withElement = resolved(svgPath.element, { area: 42 }, [1, 2, 3]);