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 new file mode 100644 index 000000000..8b0160fef --- /dev/null +++ b/packages/template/__tests__/generics.test.ts @@ -0,0 +1,401 @@ +/* eslint-disable no-inner-declarations */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import '@glint/ember-tsc/types'; +import Component from '@glimmer/component'; + +import { expectTypeOf } from 'expect-type'; +import { + emitComponent, + resolve, + resolveComponent, + resolveModifier, + NamedArgsMarker, + applyAttributes, + emitElement, + applyModifier, + emitSVGElement, +} from '../-private/dsl'; +import { ModifierLike } from '../-private'; +import { + AnyFunction, + ComponentReturn, + Invoke, + Element, + Blocks, + Invokable, + InvokableInstance, +} from '../-private/integration'; + +{ + /** + * A Link component that can sometimes be a div, if the href is missing + */ + + type Signature = Args extends { href: string } + ? { + Element: HTMLAnchorElement; + Args: Args; + Blocks: { default: [] }; + } + : { + Element: HTMLDivElement; + 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( + 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 + applyAttributes(__glintY__.element, { + // error unexpected + target: '_blank', + }); + } + + // Renders as div + { + const __glintY__ = emitComponent( + 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', + }); + } +} + +{ + /** + * 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( + 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 + applyAttributes(__glintY__.element, { + // error unexpected + value: 'hello', + // @ts-expect-error: not valid on input + wrap: 'hard', + }); + } + + // Renders as textarea + { + const __glintY__ = emitComponent( + 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 + 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', + }); + } +} + +/** + * 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( + resolveModifier(ImageModifierClass)(img.element, { + // Correct: no error expected because the img element has a src attribute + src: 'bar', + ...NamedArgsMarker, + }), + ); + + applyModifier( + resolveModifier(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( + 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: 'default boo!'; + } + interface Modifier extends InstanceType> {} + class Modifier { + constructor(args: T) {} + } + + class D3Area extends Modifier> {} + + 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]); + + applyModifier(withElement); +}