Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ class InterpolatedSignalCheck extends TemplateCheckWithVisitor<ErrorCode.INTERPO
}
// check bound inputs like `[prop]="mySignal"` on an element or inline template
else if (node instanceof TmplAstElement && node.inputs.length > 0) {
// Allow signals to be passed directly to foreign components, without invocation.
if (ctx.templateTypeChecker.getForeignComponent(component, node) !== null) {
return [];
}
const directivesOfElement = ctx.templateTypeChecker.getDirectivesOfNode(component, node);
return node.inputs.flatMap((input) =>
checkBoundAttribute(ctx, component, directivesOfElement, input),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,38 @@ runInEachFileSystem(() => {
expect(diags.length).toBe(0);
});

it('should not produce a warning when a signal is not invoked in a property binding on a foreign component', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([
{
fileName,
templates: {
'TestCmp': `<FancyButton [disabled]="mySignal"></FancyButton>`,
},
source: `
import {signal} from '@angular/core';

export function FancyButton() {}

export class TestCmp {
mySignal = signal(false);
}`,
foreignComponents: ['FancyButton'],
},
]);
const sf = getSourceFileOrError(program, fileName);
const component = getClass(sf, 'TestCmp');
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
templateTypeChecker,
program.getTypeChecker(),
[interpolatedSignalFactory],
{},
/* options */
);
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
expect(diags.length).toBe(0);
});

it('should produce a warning when a signal in a nested property read is not invoked', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([
Expand Down
50 changes: 44 additions & 6 deletions 50 packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ClassPropertyMapping,
CssSelector,
DomSchemaChecker,
ForeignComponentMeta,
MatchSource,
OutOfBandDiagnosticRecorder,
ParseSourceFile,
Expand Down Expand Up @@ -78,6 +79,7 @@ import {
AmbientImport,
ClassDeclaration,
isNamedClassDeclaration,
isNamedFunctionDeclaration,
TypeScriptReflectionHost,
} from '../../reflection';
import {
Expand Down Expand Up @@ -362,6 +364,7 @@ export function tcb(
config?: Partial<TypeCheckingConfig>,
options?: {emitSpans?: boolean},
templateParserOptions?: ParseTemplateOptions,
foreignComponents: string[] = [],
): string {
const codeLines = [
'declare const ɵNgFieldDirective: unique symbol;',
Expand Down Expand Up @@ -398,13 +401,15 @@ export function tcb(
throw new Error('Template parse errors: \n' + errors.join('\n'));
}

const {matcher, pipes} = prepareDeclarations(
const {matcher, pipes, foreignMatcher} = prepareDeclarations(
declarations,
(decl) => getClass(sf, decl.name),
new Map(),
selectorlessEnabled,
foreignComponents,
(name) => getFunction(sf, name),
);
const binder = new R3TargetBinder<DirectiveMeta>(matcher);
const binder = new R3TargetBinder<DirectiveMeta>(matcher, foreignMatcher);
const boundTarget = binder.bind({template: nodes});

const id = 'tcb' as TypeCheckId;
Expand Down Expand Up @@ -503,6 +508,11 @@ export interface TypeCheckingTarget {
* components in this file.
*/
declarations?: TestDeclaration[];

/**
* Names of foreign components that are available in the template scope.
*/
foreignComponents?: string[];
}

/**
Expand Down Expand Up @@ -622,6 +632,7 @@ export function setup(
}

const declarations = target.declarations ?? [];
const foreignComponents = target.foreignComponents ?? [];

for (const className of Object.keys(target.templates)) {
const classDecl = getClass(sf, className);
Expand All @@ -633,7 +644,7 @@ export function setup(
throw new Error('Template parse errors: \n' + errors.join('\n'));
}

const {matcher, pipes} = prepareDeclarations(
const {matcher, pipes, foreignMatcher} = prepareDeclarations(
declarations,
(decl) => {
let declFile = sf;
Expand All @@ -647,8 +658,10 @@ export function setup(
},
fakeMetadataRegistry,
overrides.parseOptions?.enableSelectorless ?? false,
foreignComponents,
(name) => getFunction(sf, name),
);
const binder = new R3TargetBinder<DirectiveMeta>(matcher);
const binder = new R3TargetBinder<DirectiveMeta>(matcher, foreignMatcher);
const classRef = new Reference(classDecl);
const templateContext: TemplateContext = {
nodes,
Expand Down Expand Up @@ -820,6 +833,8 @@ function prepareDeclarations(
resolveDeclaration: DeclarationResolver,
metadataRegistry: Map<string, TypeCheckableDirectiveMeta>,
selectorlessEnabled: boolean,
foreignComponentNames: string[] = [],
resolveForeignComponent: (name: string) => ClassDeclaration,
) {
const pipes = new Map<string, PipeMeta>();
const hostDirectiveResolder = new HostDirectivesResolver(
Expand Down Expand Up @@ -850,6 +865,17 @@ function prepareDeclarations(
}
}

const foreignRegistry = new Map<string, ForeignComponentMeta[]>();
for (const name of foreignComponentNames) {
foreignRegistry.set(name, [
{
name,
ref: new Reference(resolveForeignComponent(name)),
},
]);
}
const foreignMatcher = new SelectorlessMatcher<ForeignComponentMeta>(foreignRegistry);

// We need to make two passes over the directives so that all declarations
// have been registered by the time we resolve the host directives.

Expand All @@ -858,7 +884,7 @@ function prepareDeclarations(
for (const meta of directives) {
registry.set(meta.name, [meta, ...hostDirectiveResolder.resolve(meta)]);
}
return {matcher: new SelectorlessMatcher<DirectiveMeta>(registry), pipes};
return {matcher: new SelectorlessMatcher<DirectiveMeta>(registry), pipes, foreignMatcher};
} else {
const matcher = new SelectorMatcher<DirectiveMeta[]>();
for (const meta of directives) {
Expand All @@ -867,7 +893,7 @@ function prepareDeclarations(
matcher.addSelectables(selector, matches);
}

return {matcher, pipes};
return {matcher, pipes, foreignMatcher};
}
}

Expand All @@ -880,6 +906,18 @@ export function getClass(sf: ts.SourceFile, name: string): ClassDeclaration<ts.C
throw new Error(`Class ${name} not found in file: ${sf.fileName}: ${sf.text}`);
}

export function getFunction(
sf: ts.SourceFile,
name: string,
): ClassDeclaration<ts.FunctionDeclaration> {
for (const stmt of sf.statements) {
if (isNamedFunctionDeclaration(stmt) && stmt.name.text === name) {
return stmt;
}
}
throw new Error(`Function ${name} not found in file: ${sf.fileName}`);
}

function getDirectiveMetaFromDeclaration(
decl: TestDirective,
resolveDeclaration: DeclarationResolver,
Expand Down
8 changes: 4 additions & 4 deletions 8 packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2190,11 +2190,11 @@ runInEachFileSystem(() => {
const foreignSetupCode = `
// We must redeclare foreignImports and ForeignComponent to test them since they are marked @internal.
declare module '@angular/core' {
export interface ForeignComponent {}
export function foreignImport(render: Function): ForeignComponent;
export interface ForeignComponent<TProps> {}
export function foreignImport<TProps>(render: (props: TProps) => any): ForeignComponent<TProps>;

interface Component {
foreignImports?: ForeignComponent[];
foreignImports?: ForeignComponent<any>[];
}
}

Expand All @@ -2203,7 +2203,7 @@ runInEachFileSystem(() => {

function FancyButton() {}

function frameworkImport(component: unknown): ForeignComponent {
function frameworkImport(component: unknown): ForeignComponent<any> {
return foreignImport(() => {});
}
`;
Expand Down
17 changes: 15 additions & 2 deletions 17 packages/core/src/interface/foreign_component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,22 @@
/** Symbol used to store and retrieve the render function for a foreign component. */
export const RENDER: unique symbol = Symbol('RENDER');

/**
* A function used to render a foreign component in an Angular template.
*
* The function accepts the component's properties as its only argument. It should return an array
* of nodes rendered and owned by the foreign component. It may also return a callback to perform
* any necessary cleanup when the component is destroyed.
*
* @template TProps The properties of the foreign component.
*/
export type ForeignRenderFn<TProps> = (props: TProps) => [Node[], VoidFunction?];

/**
* Represents a component from another framework that Angular can import and render.
*
* @template TProps The properties of the foreign component.
*/
export interface ForeignComponent {
readonly [RENDER]: Function;
export interface ForeignComponent<TProps> {
readonly [RENDER]: ForeignRenderFn<TProps>;
}
2 changes: 1 addition & 1 deletion 2 packages/core/src/metadata/directives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,7 @@ export interface Component extends Directive {
*
* @internal // 3p-only
*/
foreignImports?: ForeignComponent[];
foreignImports?: ForeignComponent<any>[];
Comment thread
leonsenft marked this conversation as resolved.

/**
* The `deferredImports` property specifies a standalone component's template dependencies,
Expand Down
2 changes: 1 addition & 1 deletion 2 packages/core/src/render3/dom_node_manipulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function createElementNode(
*/
export function nativeInsertBefore(
renderer: Renderer,
parent: RElement,
parent: RNode,
child: RNode,
beforeNode: RNode | null,
isMove: boolean,
Expand Down
5 changes: 3 additions & 2 deletions 5 packages/core/src/render3/foreign_import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {ForeignComponent, RENDER} from '../interface/foreign_component';
import {ForeignComponent, ForeignRenderFn, RENDER} from '../interface/foreign_component';

/**
* Returns a {@link ForeignComponent} for use in Angular components.
*
* @template TProps The properties of the foreign component.
* @param render A function that renders a foreign component.
*/
export function foreignImport(render: Function): ForeignComponent {
export function foreignImport<TProps>(render: ForeignRenderFn<TProps>): ForeignComponent<TProps> {
return {[RENDER]: render};
}
8 changes: 6 additions & 2 deletions 8 packages/core/src/render3/foreign_view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,12 @@ export function createForeignView(lContainer: LContainer, index: number): Foreig
// 5. "Render" the view by creating the head and tail nodes, populating their slots, and marking
// the view as created. This last step is normally handled by `renderView()` for native Angular
// views with template functions.
const headComment = (lView[headTNode.index] = renderer.createComment(''));
const tailComment = (lView[tailTNode.index] = renderer.createComment(''));
const headComment = (lView[headTNode.index] = renderer.createComment(
ngDevMode ? 'foreign-view-head' : '',
));
const tailComment = (lView[tailTNode.index] = renderer.createComment(
ngDevMode ? 'foreign-view-tail' : '',
));
lView[FLAGS] &= ~LViewFlags.CreationMode;

// 6. Insert the view into the container
Expand Down
69 changes: 64 additions & 5 deletions 69 packages/core/src/render3/instructions/foreign_component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,21 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {ForeignComponent} from '../../interface/foreign_component';
import {ForeignComponent, RENDER} from '../../interface/foreign_component';
import {attachPatchData} from '../context_discovery';
import {nativeInsertBefore} from '../dom_node_manipulation';
import {createForeignView} from '../foreign_view';
import {TContainerNode, TNodeType} from '../interfaces/node';
import {HEADER_OFFSET, RENDERER} from '../interfaces/view';
import {appendChild} from '../node_manipulation';
import {getLView, getTView, setCurrentTNode, setCurrentTNodeAsNotParent} from '../state';
import {getOrCreateTNode} from '../tnode_manipulation';
import {addToEndOfViewTree} from '../view/construction';
import {createLContainer} from '../view/container';
import {NodeInjector} from '../di';
import {runInInjectionContext} from '../../di';
import {Renderer} from '../interfaces/renderer';
import {RNode} from '../interfaces/renderer_dom';

/**
* Creation phase instruction to render a foreign component.
Expand All @@ -16,10 +30,55 @@ import {ForeignComponent} from '../../interface/foreign_component';
* @param props Aggregate properties and static attributes.
* @codeGenApi
*/
export function ɵɵforeignComponent<TProps>(
export function ɵɵforeignComponent(
index: number,
foreignComponent: ForeignComponent,
props: TProps,
foreignComponent: ForeignComponent<any>,
props?: any,
): void {
// No-op for now!
const lView = getLView();
const tView = getTView();
const adjustedIndex = index + HEADER_OFFSET;

// 1. Get or create TNode for this container slot
let tNode: TContainerNode;
if (tView.firstCreatePass) {
tNode = getOrCreateTNode(tView, adjustedIndex, TNodeType.Container, null, null);
// `getOrCreateTNode` unconditionally sets the current node as a parent node, which it is not.
setCurrentTNodeAsNotParent();
} else {
tNode = tView.data[adjustedIndex] as TContainerNode;
setCurrentTNode(tNode, false);
}

// 2. Create the anchor node in the DOM
const renderer = lView[RENDERER] as Renderer;
const comment = renderer.createComment(ngDevMode ? 'foreign-component' : '');
appendChild(tView, lView, comment, tNode);
attachPatchData(comment, lView);

// 3. Create the hosting LContainer
const lContainer = createLContainer(comment, lView, comment, tNode);
lView[adjustedIndex] = lContainer;
addToEndOfViewTree(lView, lContainer);

// 4. Create the Foreign View and insert it at index 0 of the container
const viewRef = createForeignView(lContainer, 0);

// 5. Call the RENDER function to get the nodes and DisposeFn
const injector = new NodeInjector(tNode, lView);
const [nodes, dispose] = runInInjectionContext(injector, () => foreignComponent[RENDER](props));
Comment thread
leonsenft marked this conversation as resolved.

// 6. Insert the returned nodes into the foreign view, between its head and tail comment anchors.
const tail = viewRef.tail as RNode;
const parent = tail.parentNode;
if (parent) {
for (let i = 0; i < nodes.length; i++) {
nativeInsertBefore(renderer, parent, nodes[i], tail, false);
}
}

// 7. Register the DisposeFn in the foreign view's LView destroy hooks.
if (dispose) {
viewRef.onDestroy(dispose);
}
}
Loading
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.