Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions packages/ember-tsc/src/transform/diagnostics/augmentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ function noteNamedArgsAffectArity(

return {
...diagnostic,
messageText: `${diagnostic.messageText} ${note}`,
messageText: `${formatDiagnosticMessageText(diagnostic.messageText)} ${note}`,
};
}
}
Expand Down Expand Up @@ -244,6 +244,16 @@ function checkResolveError(
`missing a registry entry for this name; see the Template Registry page in the Glint ` +
`documentation for more details.`,
);
} else if (parentNode.hash.pairs.length > 0) {
return {
...diagnostic,
messageText: `Unable to pre-bind the given args to the given ${kind}. This likely indicates a type mismatch between its signature and the values you're passing.`,
};
} else if (hasExpectedPropertyRelatedInfo(diagnostic)) {
return {
...diagnostic,
messageText: formatDiagnosticMessageText(diagnostic.messageText),
};
} else {
return addGlintDetails(
diagnostic,
Expand Down Expand Up @@ -313,10 +323,38 @@ function checkIndexAccessError(
function addGlintDetails(diagnostic: Diagnostic, details: string): Diagnostic {
return {
...diagnostic,
messageText: `${details}\n${diagnostic.messageText}`,
messageText: `${details}\n${formatDiagnosticMessageText(diagnostic.messageText)}`,
};
}

function hasExpectedPropertyRelatedInfo(diagnostic: Diagnostic): boolean {
return (
diagnostic.relatedInformation?.some((related) =>
formatDiagnosticMessageText(related.messageText).startsWith(
"The expected type comes from property '",
),
) ?? false
);
}

function formatDiagnosticMessageText(
messageText: string | ts.DiagnosticMessageChain,
indent = 0,
): string {
if (typeof messageText === 'string') {
return messageText;
}

let padding = ' '.repeat(indent);
let lines = [`${padding}${messageText.messageText}`];

for (let child of messageText.next ?? []) {
lines.push(formatDiagnosticMessageText(child, indent + 1));
}

return lines.join('\n');
}

// Find the nearest mapping node at or above the given one whose `source` AST node
// matches one of the given types.
function findAncestor<K extends MappingSource['type']>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,13 +310,34 @@ export function templateToTypescript(
// invokable is the source of record for its own type and we don't want inference
// from the `resolveForBind` call to be affected by other (potentially incorrect)
// parameter types.
mapper.text('__glintDSL__.resolve(');
emitExpression(node.path);
mapper.text(')((() => __glintDSL__.resolveForBind(');
emitExpression(node.params[0]);
mapper.text('))(), ');
emitArgs(node.params.slice(1), node.hash);
mapper.text(')');
//
// `{{component}}` with bound named args is a special case: keeping the original
// component class available lets the keyword derive its bound type from the
// class's context directly, which avoids the excessively deep type expansion
// triggered by the generic `resolveForBind` path.
let usesDirectComponentFastPath =
formInfo.name === 'component' &&
node.params.length === 1 &&
node.hash.pairs.length > 0 &&
node.params[0].type === 'PathExpression';

if (usesDirectComponentFastPath) {
mapper.text('__glintDSL__.resolve(');
emitExpression(node.path);
mapper.text(')(');
emitExpression(node.params[0]);
mapper.text(', ');
emitArgs(node.params.slice(1), node.hash);
mapper.text(')');
} else {
mapper.text('__glintDSL__.resolve(');
emitExpression(node.path);
mapper.text(')((() => __glintDSL__.resolveForBind(');
emitExpression(node.params[0]);
mapper.text('))(), ');
emitArgs(node.params.slice(1), node.hash);
mapper.text(')');
}

if (position === 'top-level') {
mapper.text(')');
Expand Down
94 changes: 92 additions & 2 deletions packages/template/-private/keywords/component.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,94 @@
import { ComponentReturn } from '../integration';
import {
AnyFunction,
AnyContext,
ComponentReturn,
DirectInvokable,
FlattenBlockParams,
HasContext,
Invokable,
InvokeDirect,
NamedArgNames,
NamedArgs,
UnwrapNamedArgs,
} from '../integration';
import { ComponentLike, WithBoundArgs } from '../index';
import {
ComponentSignatureArgs,
ComponentSignatureElement,
Get,
MaybeNamed,
PrebindArgs,
} from '../signature';
import { BindInvokableKeyword } from './-bind-invokable';

export type ComponentKeyword = BindInvokableKeyword<0, ComponentReturn<any, any>>;
type BoundComponentFromContext<Context extends AnyContext, GivenKeys extends PropertyKey> = Invokable<
(...named: MaybeNamed<PrebindArgs<NamedArgs<Context['args']>, GivenKeys>>) => ComponentReturn<
FlattenBlockParams<Context['blocks']>,
Context['element']
>
>;

type BoundComponentLikeFromSignature<
S,
GivenNamed extends keyof ComponentSignatureArgs<S>['Named'],
> = ComponentLike<{
Args: S extends { Args: infer Args }
? Args extends { Named?: object; Positional?: unknown[] }
? {
Named: PrebindArgs<
Get<S['Args'], 'Named', {}>,
GivenNamed & keyof Get<S['Args'], 'Named', {}>
>;
Positional: Get<S['Args'], 'Positional', []>;
}
: PrebindArgs<Get<S, 'Args', {}>, GivenNamed & keyof Get<S, 'Args', {}>>
: PrebindArgs<{}, never>;
Blocks: Get<S, 'Blocks', {}>;
Element: ComponentSignatureElement<S>;
}>;

type SignatureFor<T extends ComponentLike<any>> = T extends ComponentLike<infer S> ? S : never;

type ComponentLikeNamedArgs<T extends Invokable<AnyFunction>> =
T extends Invokable<(...args: infer Args) => ComponentReturn<any, any>>
? Args extends [...positional: infer _, named?: infer Named]
? UnwrapNamedArgs<NonNullable<Named>>
: never
: never;

type ComponentKeywordBase = BindInvokableKeyword<0, ComponentReturn<any, any>>[typeof InvokeDirect];

type ComponentKeywordFastPath = {
<Context extends AnyContext, C extends abstract new (...args: any[]) => HasContext<Context>, GivenNamed extends Partial<Context['args']>>(
component: C,
named: NamedArgs<GivenNamed>,
): BoundComponentFromContext<Context, keyof GivenNamed>;
<Context extends AnyContext, C extends abstract new (...args: any[]) => HasContext<Context>, GivenNamed extends Partial<Context['args']>>(
component: C | null | undefined,
named: NamedArgs<GivenNamed>,
): null | BoundComponentFromContext<Context, keyof GivenNamed>;
<T extends ComponentLike<any>, GivenNamed extends Partial<ComponentSignatureArgs<SignatureFor<T>>['Named']>>(
component: T,
named: NamedArgs<GivenNamed>,
): BoundComponentLikeFromSignature<
SignatureFor<T>,
keyof GivenNamed & keyof ComponentSignatureArgs<SignatureFor<T>>['Named']
>;
<T extends ComponentLike<any>, GivenNamed extends Partial<ComponentSignatureArgs<SignatureFor<T>>['Named']>>(
component: T | null | undefined,
named: NamedArgs<GivenNamed>,
): null | BoundComponentLikeFromSignature<
SignatureFor<T>,
keyof GivenNamed & keyof ComponentSignatureArgs<SignatureFor<T>>['Named']
>;
<T extends Invokable<AnyFunction>, GivenNamed extends NamedArgNames<T>>(
component: T,
named: NamedArgs<Partial<ComponentLikeNamedArgs<T>> & Pick<ComponentLikeNamedArgs<T>, GivenNamed>>,
): WithBoundArgs<T, GivenNamed>;
<T extends Invokable<AnyFunction>, GivenNamed extends NamedArgNames<T>>(
component: T | null | undefined,
named: NamedArgs<Partial<ComponentLikeNamedArgs<T>> & Pick<ComponentLikeNamedArgs<T>, GivenNamed>>,
): null | WithBoundArgs<T, GivenNamed>;
};

export type ComponentKeyword = DirectInvokable<ComponentKeywordFastPath & ComponentKeywordBase>;
Original file line number Diff line number Diff line change
Expand Up @@ -319,10 +319,10 @@ describe('Language Server: Diagnostic Augmentation', () => {
[
{
"category": "error",
"code": 2345,
"code": 2769,
"end": {
"line": 9,
"offset": 28,
"offset": 20,
},
"relatedInformation": [
{
Expand All @@ -344,14 +344,9 @@ describe('Language Server: Diagnostic Augmentation', () => {
],
"start": {
"line": 9,
"offset": 21,
"offset": 16,
},
"text": "Argument of type '[{ [NamedArgs]: true; foo: number; }]' is not assignable to parameter of type '[] | [NamedArgs<{ foo: string; }>]'.
Type '[{ [NamedArgs]: true; foo: number; }]' is not assignable to type '[NamedArgs<{ foo: string; }>]'.
Type '{ [NamedArgs]: true; foo: number; }' is not assignable to type 'NamedArgs<{ foo: string; }>'.
Type '{ [NamedArgs]: true; foo: number; }' is not assignable to type '{ foo: string; }'.
Types of property 'foo' are incompatible.
Type 'number' is not assignable to type 'string'.",
"text": "Unable to pre-bind the given args to the given component. This likely indicates a type mismatch between its signature and the values you're passing.",
},
{
"category": "error",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,68 @@ describe('Transform: rewriteModule', () => {
`);
});

test('component currying keeps the component class as the first argument', () => {
let script = {
filename: 'test.gts',
contents: stripIndent`
import Component from '@glimmer/component';

class Child<T> extends Component<{
Args: { value: T; onChange: (v: T) => void };
}> {}

export default class Parent<T> extends Component<{
Args: { onChange: (v: T) => void };
}> {
<template>
{{component Child onChange=@onChange}}
</template>
}
`,
};

let transformedModule = rewriteModule(ts, { script }, env);

expect(transformedModule?.errors).toEqual([]);
expect(transformedModule?.transformedContents).toMatchInlineSnapshot(`
"import Component from '@glimmer/component';

class Child<T> extends Component<{
Args: { value: T; onChange: (v: T) => void };
}> {}

export default class Parent<T> extends Component<{
Args: { onChange: (v: T) => void };
}> {
static { ({} as typeof import("@glint/ember-tsc/-private/dsl")).templateForBackingValue(this, function(__glintRef__, __glintDSL__: typeof import("@glint/ember-tsc/-private/dsl")) {
__glintDSL__.emitContent(__glintDSL__.resolve(__glintDSL__.Globals["component"])(Child, { onChange: __glintRef__.args.onChange , ...__glintDSL__.NamedArgsMarker }));
__glintRef__; __glintDSL__;
}) }
}"
`);
});

test('string-based component currying keeps resolveForBind', () => {
let script = {
filename: 'test.gts',
contents: stripIndent`
export default <template>
{{component 'link-to' route='widgets'}}
</template>
`,
};

let transformedModule = rewriteModule(ts, { script }, env);

expect(transformedModule?.errors).toEqual([]);
expect(transformedModule?.transformedContents).toMatchInlineSnapshot(`
"export default ({} as typeof import("@glint/ember-tsc/-private/dsl")).templateExpression(function(__glintRef__, __glintDSL__: typeof import("@glint/ember-tsc/-private/dsl")) {
__glintDSL__.emitContent(__glintDSL__.resolve(__glintDSL__.Globals["component"])((() => __glintDSL__.resolveForBind("link-to"))(), { route: "widgets" , ...__glintDSL__.NamedArgsMarker }));
__glintRef__; __glintDSL__;
})"
`);
});

test('with an anonymous class', () => {
let script = {
filename: 'test.gts',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
yieldToBlock,
} from '@glint/ember-tsc/-private/dsl';
import { ComponentLike } from '@glint/template';
import type { ComponentKeyword } from '@glint/template/-private/keywords/component';
import { expectTypeOf } from 'expect-type';

{
Expand Down Expand Up @@ -122,6 +123,44 @@ import { expectTypeOf } from 'expect-type';
}
}

{
const componentKeyword = resolve({} as ComponentKeyword);

class YieldedCurriedChild<T> extends Component<{
Args: { value: T; onChange: (v: T) => void };
Blocks: { default: [] };
}> {
static {
templateForBackingValue(this, function (__glintRef__) {
yieldToBlock(__glintRef__, 'default')();
});
}
}

class ParentYieldingCurriedChild<T> extends Component<{
Args: { onChange: (v: T) => void };
Blocks: { default: [ComponentLike<{ Args: { value: T } }>] };
}> {
static {
templateForBackingValue(this, function (__glintRef__) {
yieldToBlock(__glintRef__, 'default')(
componentKeyword(YieldedCurriedChild, {
onChange: __glintRef__.args.onChange,
...NamedArgsMarker,
}),
);
});
}
}

emitComponent(
resolve(ParentYieldingCurriedChild)({
onChange: (value: string) => value,
...NamedArgsMarker,
}),
);
}

// Components are `ComponentLike`
{
interface TestSignature {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,25 @@ typeTest(
`,
);

// Binding named args on an already-curried component should preserve its bound shape
typeTest(
{ ParametricComponent, formModifier },
hbs`
{{#let (component this.ParametricComponent values=(array "hi")) as |RequiredValueCurriedParametricComponent|}}
{{#let (component RequiredValueCurriedParametricComponent optional="ok") as |DoubleCurriedComponent|}}
<DoubleCurriedComponent />
<DoubleCurriedComponent @values={{array "override"}} {{this.formModifier}} as |value index|>
{{@expectTypeOf value @to.beString}}
{{@expectTypeOf index @to.beNumber}}
</DoubleCurriedComponent>

{{! @glint-expect-error: wrong type for inherited arg }}
<DoubleCurriedComponent @values={{array 1 2 3}} />
{{/let}}
{{/let}}
`,
);

// Binding an optional arg still leaves the required one(s)
typeTest(
{ ParametricComponent, formModifier },
Expand Down