diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/index.ts index 5060321e19a2..77f329a14a85 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/index.ts @@ -61,6 +61,10 @@ class InterpolatedSignalCheck extends TemplateCheckWithVisitor 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), diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/interpolated_signal_not_invoked_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/interpolated_signal_not_invoked_spec.ts index 342ea892d915..260dc446780d 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/interpolated_signal_not_invoked_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/interpolated_signal_not_invoked_spec.ts @@ -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': ``, + }, + 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([ diff --git a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts index 03de2753259e..e2a65f0552bf 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts @@ -12,6 +12,7 @@ import { ClassPropertyMapping, CssSelector, DomSchemaChecker, + ForeignComponentMeta, MatchSource, OutOfBandDiagnosticRecorder, ParseSourceFile, @@ -78,6 +79,7 @@ import { AmbientImport, ClassDeclaration, isNamedClassDeclaration, + isNamedFunctionDeclaration, TypeScriptReflectionHost, } from '../../reflection'; import { @@ -362,6 +364,7 @@ export function tcb( config?: Partial, options?: {emitSpans?: boolean}, templateParserOptions?: ParseTemplateOptions, + foreignComponents: string[] = [], ): string { const codeLines = [ 'declare const ɵNgFieldDirective: unique symbol;', @@ -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(matcher); + const binder = new R3TargetBinder(matcher, foreignMatcher); const boundTarget = binder.bind({template: nodes}); const id = 'tcb' as TypeCheckId; @@ -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[]; } /** @@ -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); @@ -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; @@ -647,8 +658,10 @@ export function setup( }, fakeMetadataRegistry, overrides.parseOptions?.enableSelectorless ?? false, + foreignComponents, + (name) => getFunction(sf, name), ); - const binder = new R3TargetBinder(matcher); + const binder = new R3TargetBinder(matcher, foreignMatcher); const classRef = new Reference(classDecl); const templateContext: TemplateContext = { nodes, @@ -820,6 +833,8 @@ function prepareDeclarations( resolveDeclaration: DeclarationResolver, metadataRegistry: Map, selectorlessEnabled: boolean, + foreignComponentNames: string[] = [], + resolveForeignComponent: (name: string) => ClassDeclaration, ) { const pipes = new Map(); const hostDirectiveResolder = new HostDirectivesResolver( @@ -850,6 +865,17 @@ function prepareDeclarations( } } + const foreignRegistry = new Map(); + for (const name of foreignComponentNames) { + foreignRegistry.set(name, [ + { + name, + ref: new Reference(resolveForeignComponent(name)), + }, + ]); + } + const foreignMatcher = new SelectorlessMatcher(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. @@ -858,7 +884,7 @@ function prepareDeclarations( for (const meta of directives) { registry.set(meta.name, [meta, ...hostDirectiveResolder.resolve(meta)]); } - return {matcher: new SelectorlessMatcher(registry), pipes}; + return {matcher: new SelectorlessMatcher(registry), pipes, foreignMatcher}; } else { const matcher = new SelectorMatcher(); for (const meta of directives) { @@ -867,7 +893,7 @@ function prepareDeclarations( matcher.addSelectables(selector, matches); } - return {matcher, pipes}; + return {matcher, pipes, foreignMatcher}; } } @@ -880,6 +906,18 @@ export function getClass(sf: ts.SourceFile, name: string): ClassDeclaration { + 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, diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index 6024b1bd862d..1cf9fbfefd2c 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -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 {} + export function foreignImport(render: (props: TProps) => any): ForeignComponent; interface Component { - foreignImports?: ForeignComponent[]; + foreignImports?: ForeignComponent[]; } } @@ -2203,7 +2203,7 @@ runInEachFileSystem(() => { function FancyButton() {} - function frameworkImport(component: unknown): ForeignComponent { + function frameworkImport(component: unknown): ForeignComponent { return foreignImport(() => {}); } `; diff --git a/packages/core/src/interface/foreign_component.ts b/packages/core/src/interface/foreign_component.ts index 81025f90a5f5..25c7dc14e84e 100644 --- a/packages/core/src/interface/foreign_component.ts +++ b/packages/core/src/interface/foreign_component.ts @@ -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 = (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 { + readonly [RENDER]: ForeignRenderFn; } diff --git a/packages/core/src/metadata/directives.ts b/packages/core/src/metadata/directives.ts index 11c923115a82..681337355c2b 100644 --- a/packages/core/src/metadata/directives.ts +++ b/packages/core/src/metadata/directives.ts @@ -648,7 +648,7 @@ export interface Component extends Directive { * * @internal // 3p-only */ - foreignImports?: ForeignComponent[]; + foreignImports?: ForeignComponent[]; /** * The `deferredImports` property specifies a standalone component's template dependencies, diff --git a/packages/core/src/render3/dom_node_manipulation.ts b/packages/core/src/render3/dom_node_manipulation.ts index d7258756843b..ea7f47609bc1 100644 --- a/packages/core/src/render3/dom_node_manipulation.ts +++ b/packages/core/src/render3/dom_node_manipulation.ts @@ -46,7 +46,7 @@ export function createElementNode( */ export function nativeInsertBefore( renderer: Renderer, - parent: RElement, + parent: RNode, child: RNode, beforeNode: RNode | null, isMove: boolean, diff --git a/packages/core/src/render3/foreign_import.ts b/packages/core/src/render3/foreign_import.ts index d8d6fa5bb2cb..8bfd6a28fcce 100644 --- a/packages/core/src/render3/foreign_import.ts +++ b/packages/core/src/render3/foreign_import.ts @@ -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(render: ForeignRenderFn): ForeignComponent { return {[RENDER]: render}; } diff --git a/packages/core/src/render3/foreign_view.ts b/packages/core/src/render3/foreign_view.ts index 359577ec5379..c610ef7303d9 100644 --- a/packages/core/src/render3/foreign_view.ts +++ b/packages/core/src/render3/foreign_view.ts @@ -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 diff --git a/packages/core/src/render3/instructions/foreign_component.ts b/packages/core/src/render3/instructions/foreign_component.ts index 4d4958ba34a7..852312983b21 100644 --- a/packages/core/src/render3/instructions/foreign_component.ts +++ b/packages/core/src/render3/instructions/foreign_component.ts @@ -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. @@ -16,10 +30,55 @@ import {ForeignComponent} from '../../interface/foreign_component'; * @param props Aggregate properties and static attributes. * @codeGenApi */ -export function ɵɵforeignComponent( +export function ɵɵforeignComponent( index: number, - foreignComponent: ForeignComponent, - props: TProps, + foreignComponent: ForeignComponent, + 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)); + + // 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); + } } diff --git a/packages/core/test/render3/foreign_component_spec.ts b/packages/core/test/render3/foreign_component_spec.ts new file mode 100644 index 000000000000..fd4308f86c89 --- /dev/null +++ b/packages/core/test/render3/foreign_component_spec.ts @@ -0,0 +1,231 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {ɵɵforeignComponent} from '../../src/render3/instructions/foreign_component'; +import {foreignImport} from '../../src/render3/foreign_import'; +import {destroyLView} from '../../src/render3/node_manipulation'; +import {ViewFixture} from './view_fixture'; +import {ɵɵelement, ɵɵelementEnd, ɵɵelementStart} from '../../src/render3/instructions/element'; +import {inject, InjectionToken} from '../../src/di'; +import {ɵɵdefineDirective} from '../../src/render3/definition'; +import {ɵɵProvidersFeature} from '../../src/render3/features/providers_feature'; +import {createLView} from '../../src/render3/view/construction'; +import {renderView} from '../../src/render3/instructions/render'; +import {LView, LViewFlags, PARENT, RENDERER, T_HOST} from '../../src/render3/interfaces/view'; + +describe('ɵɵforeignComponent', () => { + afterEach(ViewFixture.cleanUp); + + it("should render a foreign component's native elements", () => { + const foreignComp = foreignImport(() => { + const el = document.createElement('div'); + el.id = 'foreign-el'; + el.textContent = 'Foreign Content'; + return [[el]]; + }); + + const fixture = new ViewFixture({ + decls: 1, + vars: 0, + create: () => { + ɵɵforeignComponent(0, foreignComp); + }, + }); + + expect(fixture.host.innerHTML).toContain('
Foreign Content
'); + }); + + it('should pass props to a foreign component', () => { + let passedProps: any = null; + const foreignComp = foreignImport<{name: string}>((props) => { + passedProps = props; + return [[]]; + }); + + new ViewFixture({ + decls: 1, + vars: 0, + create: () => { + ɵɵforeignComponent(0, foreignComp, {name: 'Angular'}); + }, + }); + + expect(passedProps).toEqual({name: 'Angular'}); + }); + + it('should call the dispose function when the containing view is destroyed', () => { + let disposeCalled = false; + const foreignComp = foreignImport(() => { + return [ + [], + () => { + disposeCalled = true; + }, + ]; + }); + + const fixture = new ViewFixture({ + decls: 1, + vars: 0, + create: () => { + ɵɵforeignComponent(0, foreignComp); + }, + }); + + expect(disposeCalled).toBeFalse(); + + destroyLView(fixture.tView, fixture.lView); + + expect(disposeCalled).toBeTrue(); + }); + + it('should render foreign view between sibling elements', () => { + const foreignComp = foreignImport(() => { + const el = document.createElement('div'); + el.textContent = 'Foreign Content'; + return [[el]]; + }); + + const fixture = new ViewFixture({ + decls: 3, + vars: 0, + create: () => { + ɵɵelement(0, 'p'); + ɵɵforeignComponent(1, foreignComp); + ɵɵelement(2, 'span'); + }, + }); + + expect(fixture.host.innerHTML).toContain( + '' + + '

' + + '' + + '
Foreign Content
' + + '' + + '' + + '', + ); + }); + + it('should render foreign view as a child of a parent element', () => { + const foreignComp = foreignImport(() => { + const el = document.createElement('span'); + el.textContent = 'Foreign Content'; + return [[el]]; + }); + + const fixture = new ViewFixture({ + decls: 2, + vars: 0, + create: () => { + ɵɵelementStart(0, 'div'); + ɵɵforeignComponent(1, foreignComp); + ɵɵelementEnd(); + }, + }); + + expect(fixture.host.innerHTML).toContain( + '' + + '
' + + '' + + 'Foreign Content' + + '' + + '' + + '
', + ); + }); + + it('should execute the RENDER function inside the template injection context', () => { + const TEST_TOKEN = new InjectionToken('test-token'); + + const foreignComp = foreignImport(() => { + const value = inject(TEST_TOKEN, {optional: true}) ?? 'null'; + const el = document.createElement('div'); + el.id = 'foreign-el'; + el.textContent = value; + return [[el]]; + }); + + class ProviderDirective { + static ɵfac = () => new ProviderDirective(); + static ɵdir = ɵɵdefineDirective({ + type: ProviderDirective, + selectors: [['', 'provider-dir', '']], + features: [ɵɵProvidersFeature([{provide: TEST_TOKEN, useValue: 'templated-value'}])], + }); + } + + const fixture = new ViewFixture({ + decls: 2, + vars: 0, + consts: [['provider-dir', '']], + directives: [ProviderDirective], + create: () => { + ɵɵelementStart(0, 'div', 0); + ɵɵforeignComponent(1, foreignComp); + ɵɵelementEnd(); + }, + }); + + expect(fixture.host.innerHTML).toContain('
templated-value
'); + }); + + it('should support reusing the same template between multiple view instances', () => { + const foreignComp1 = foreignImport(() => { + return [[document.createTextNode('foreign content')]]; + }); + + const createFn = () => { + ɵɵelementStart(0, 'div'); + ɵɵforeignComponent(1, foreignComp1); + ɵɵelementEnd(); + }; + const expectedHtml = + '' + + '
' + + 'foreign content' + + '' + + '
'; + + const fixture = new ViewFixture({ + decls: 2, + vars: 0, + create: createFn, + }); + expect(fixture.host.innerHTML).toContain(expectedHtml); + + // Create second instance reusing the TView + const host2 = renderSecondInstance(fixture); + expect(fixture.host.innerHTML).toContain(expectedHtml); + expect(host2.innerHTML).toContain(expectedHtml); + }); +}); + +function renderSecondInstance(fixture: ViewFixture): HTMLElement { + const hostLView = fixture.lView[PARENT] as LView; + const hostTNode = fixture.lView[T_HOST]; + const hostRenderer = hostLView[RENDERER]; + const host = hostRenderer.createElement('host-element') as HTMLElement; + + const lView = createLView( + hostLView, + fixture.tView, + {}, + LViewFlags.CheckAlways, + host, + hostTNode, + null, + null, + null, + null, + null, + ); + + renderView(fixture.tView, lView, {}); + return host; +} diff --git a/packages/core/test/render3/foreign_view_spec.ts b/packages/core/test/render3/foreign_view_spec.ts index 43125c1bb333..452aa9195ba0 100644 --- a/packages/core/test/render3/foreign_view_spec.ts +++ b/packages/core/test/render3/foreign_view_spec.ts @@ -123,7 +123,17 @@ describe('foreign views', () => { vcr2.insert(viewRef); expect(fixture.nativeElement.innerHTML).toBe( - '
', + '' + + '
' + + // First
container + '' + + '
' + + // Foreign view + '' + + '' + + '' + + // Second
container + '', ); });