Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Imperative View & Template Composition APIs #43120

Open
IgorMinar opened this issue Aug 11, 2021 · 16 comments
Open

Imperative View & Template Composition APIs #43120

IgorMinar opened this issue Aug 11, 2021 · 16 comments

Comments

@IgorMinar
Copy link
Member

@IgorMinar IgorMinar commented Aug 11, 2021

Description

While declarative templating is great for most cases, developers often need more flexibility to create UIs dynamically, using components and directives (often) not known at the time of writing the component that generates this UI. Common use cases include data driven UIs (including forms), A/B experiments, UIs generated via plugin systems, etc.

Angular currently enables dynamic view or template creation only with major drawbacks in two ways:

  1. Via the JIT compiler, which can take any string and compile it on the fly within the browser. This approach compromises security of the application (there is a high risk of introducing XSS and compromising the overall security model of Angular) and also comes with a major performance penalty due to the runtime cost of the compilation, and payload size of the overhead of the compiler.

  2. Via ViewContainerRef#createComponent and similar APIs, which don't offer enough flexibility to dynamically compose views out of a mixture of static HTML, HTML with dynamically created bindings, components, and directives.

Proposed solution

With Ivy rendering/compilation pipeline being the default and ViewEngine being removed in v13, we can finally start taking advantage of flexibility of Ivy without having to worry about compatibility with ViewEngine, this opens up new possibilities including the follow...

We could introduce a new set of APIs that are built specifically for dynamic view composition. A proper research needs to be conducted to determine the shape of the API, but we can use the following PLACEHOLDER APIs to demonstrate the functionality and serve as a foundation for discussion, brainstorming, and actual API design research...

@Component({
   ...
   template: '<button (click)="renderDynamically()">render</button>'
   ...
})
class DemoComponent {
  constructor(private viewContainerRef: ViewContainerRef) {}

  protected async renderDynamically() {

    const myCustomView = await createMyCustomView('./myBlue.component',
      {userName: 'Kahless', myRoutePath: ['my-route'], showDetails: true});

    // appends the view
    viewContainerRef.appendEmbeddedView(myCustomView);
  }
}

Where the templateRef is generated dynamically as follows:

// this doesn't exist yet + API design is to be determined
import {createView, html, component, directive, text, binding, template} from "@angular/core/compose";
import {NgIf} from "@angular/core";
import {RouterLink} from "@angular/router";
import {MyRedComponent} "./myRed.component";
import {MyYellowComponent} "./myYellow.component";

async function createMyCustomView(blueComponentESModulePath, myContext) {
  const blueComponent = (await import(blueComponentESModulePath)).default;

  // this is where the magic happens
  return createView([

    html("<div>some static html, that will be automatically serialized by the `createTemplate` call</div>"),
    component(MyYellowComponent, { projection: [
      text("html string followed by a binding"),

      // {{ userName }}
      binding(() => myContext.userName),

      // you can use javascript expressions instead of `NgIf` to conditionally add view "nodes":
      (Math.random() > 0.5) && text(" and followed by another component"),

      // <my-red [routeLink]="myRoutePath"></my-red>
      component(MyRedComponent, { directives: [
        directive(RouterLink , {binding: () => myContext.myRoutePath})
      ]}),

      // but NgIf works as well:
      // <p *ngIf="showDetails">Some details</p>
      template([
       text("some details")
       ], {directives: [
         directive(NgIf, {binding: () => myContext.showDetails})
       ]})

       // NgForOf, NgSwitch, and any custom regular or structural directives would work as well
       // TODO: update the proposal with a built-in support for structural directives in `@angular/common`,
       //              e.g. `ngIfTemplate` - see suggestion from @sg-gdarnell in the comments below.
    ]})
  ]);
}

How would this be implemented?

Functions template, component, directive, text would be just higher level wrappers around the private Ivy instruction APIs.

Risks & unresolve stuff

There is a lot that needs to be researched in this area to determine the complexity of the implementation and overall viability. The following is an incomplete list of unresolved questions / issues:

  • The existing ViewContainerRef APIs accept only TemplateRefs or Components and not views directly. Is this a problem?
  • Do we need to support dynamic template composition (outputting TemplateRef) as well? Or is view composition sufficient?
  • Are current Ivy instruction APIs flexible enough to accomodate this feature or do we need to tweak them?
  • Can we keep all existing security guarantees to prevent XSS?
  • How to attach the new view to the correct injector in the parent?
  • Is the existing $localize API sufficient to support localization?
  • Should we limit this API just to upcoming "standalone" components, directives, pipes, or can we make this API work with entities that are part of an NgModule without a major hassle?
  • and many more...

As a result of these, you can expect major revisions of the proposed solution. The goal of this effort and use-cases this functionality would support should however remain the same.

Are there any trade offs?

Of course, there are always some trade offs.

The main ones that come to my mind:

  • We don't plan on supporting templates as strings. Only static html or text (without bindings, Angular components or directives) could be provided as string) — such feature would require the availability of the entire JIT compiler at runtime, which would go against the very core goal of this proposal.
  • Performance of creation of dynamically composed views will likely not be as good as that of AOT compiled code. The update performance would be less impacted. There are ways we could improve perf of dynamically created views (e.g. by supporting dynamic template creation), but more research is needed.
  • The API is more verbose than the declarative template syntax. Since the goal here is to enable dynamic view creation using imperative JavaScript/TypeScript code, we are trading off some verbosity for flexibility of the imperative coding.
  • Limited tree-shaking impact — the higher level API might need to retain a few instructions that would otherwise be tree-shaken away if the template was created using AOT compilation, but the impact should be very limited, especially if each higher level API is a standalone tree-shakable function (as proposed in the example above).

What's the goal of this issue?

The goal is to write down some of the ideas that have been made possible thanks to Ivy and start collecting early feedback that could be used as an input for future RFC.

Alternatives considered

Expose the Ivy instructions as a public API. This has a major drawback that these APIs are low level (not developer friendly) and not stable (it would be hard to evolve the rendering stack if these APIs were public).

@ngbot ngbot bot added this to the Backlog milestone Aug 11, 2021
@ngbot ngbot bot added this to the Backlog milestone Aug 11, 2021
@ngbot ngbot bot added this to the Backlog milestone Aug 11, 2021
@ngbot ngbot bot added this to the Backlog milestone Aug 11, 2021
@SanderElias
Copy link
Member

@SanderElias SanderElias commented Aug 12, 2021

Question: Isn't it possible to create a small public layer on top of the Ivy instruction set?
This will create a low-level API that still allows evolving the rendering stack, while also providing a flexible basis to build higher-level abstractions.

@SanderElias
Copy link
Member

@SanderElias SanderElias commented Aug 12, 2021

Another question: We can already mix-and-match more dynamic templates by using Angular Elements(web components generated by angular).
The current downside of using those is that you can't tell the language server you have those, so you need to turn off most of the syntax checking for templates.
Would fixing that, not already bring a lot of what this RFC is bringing?

@SanderElias
Copy link
Member

@SanderElias SanderElias commented Aug 12, 2021

@IgorMinar Thanks for this proposal! It really looks good.
What I'm missing in the createView array, is an option to add a portion using normal template syntax, something like:

  // this is where the magic happens
  return createView([
    html("<div>some static html, that will be automatically serialized by the `createTemplate` call</div>"),
    tempateText(`
          <app-user [userId]="userId"><app-user>
           <ul>
              <li *nfFor="let pref of preferences>{{pref.name} - {{pref.value}}</li>
           </ul>`)
     } ) ; 
    } ]) 
  ] ) ; 

Where it uses the same context as the myContext in your sample for the template.

Adding support for "normal" templates will name the use of this API a lot simpler. I know using it will need to ship the full Ivy compiler to the client, but I would like to be able to make that trade-off.

@alxhub
Copy link
Contributor

@alxhub alxhub commented Aug 12, 2021

Question: Isn't it possible to create a small public layer on top of the Ivy instruction set?

I think that's exactly what this is - it's an API to imperatively construct an Ivy template function, but which doesn't require you to have specific knowledge about how to call the actual template instructions.

We can already mix-and-match more dynamic templates by using Angular Elements(web components generated by angular).
The current downside of using those is that you can't tell the language server you have those, so you need to turn off most of the syntax checking for templates.
Would fixing that, not already bring a lot of what this RFC is bringing?

I don't think so, because such components still need to be written statically, even if their instantiation is more dynamic. This proposal allows for very dynamic constructions of views, based on runtime data structures.

What I'm missing in the createView array, is an option to add a portion using normal template syntax,

This is a cool idea, too. One of the concerns here is that it would require some notion of a template scope (what @NgModule provides today for statically compiled components) and that might get very tricky or messy to specify. But it could be done.

@SanderElias
Copy link
Member

@SanderElias SanderElias commented Aug 12, 2021

@alxhub Thanks for the quick answers! It is good to have this cleared out of the way.
I do like your clarification on how this is a different dynamic as using Angular elements.

This is a cool idea, too. One of the concerns here is that it would require some notion of a template scope (what @NgModule provides today for statically compiled components) and that might get very tricky or messy to specify. But it could be done.

This would be the same thing as it is for stand-alone(module-less) components, or am I looking from the wrong side? Those problems need to be solved anyway for truly dynamic components as far as I can see.

@alxhub
Copy link
Contributor

@alxhub alxhub commented Aug 12, 2021

@alxhub Thanks for the quick answers! It is good to have this cleared out of the way.
I do like your clarification on how this is a different dynamic as using Angular elements.

This is a cool idea, too. One of the concerns here is that it would require some notion of a template scope (what @NgModule provides today for statically compiled components) and that might get very tricky or messy to specify. But it could be done.

This would be the same thing as it is for stand-alone(module-less) components, or am I looking from the wrong side? Those problems need to be solved anyway for truly dynamic components as far as I can see.

Sort of. The problem is that this scope has to be defined at runtime instead of compile time.

One of the challenges here is that Angular actually erases scope information for NgModules during compilation - this prevents all components/directives/pipes in an application from being referenced by NgModules and thus ineligible for tree-shaking. Having a dynamic, runtime way to specify a compilation scope would require retaining this information across the board, since we can't know ahead of time which NgModules would be referenced from such a dynamic scope.

@IgorMinar
Copy link
Member Author

@IgorMinar IgorMinar commented Aug 12, 2021

@SanderElias thanks for all the questions. I think @alxhub fielded all/most of them already.

I just want to chime in on:

    tempateText(`
          <app-user [userId]="userId"><app-user>
           <ul>
              <li *nfFor="let pref of preferences>{{pref.name} - {{pref.value}}</li>
           </ul>`)
     } ) ; 

Dynamic template creation out of templates is something that I'd really like to avoid - it would basically be just a glorified way of using the current JIT compiler in production, which has lots and lots of problems (critical security, performance, and maintenance issues). I have an RFC coming out today, exploring the possibility of eventually removing JIT compilation mode entirely from Angular. I should post it within the next few hours — please stay tuned.

The goal of this proposal is to enable dynamic composition of views in a way that is performant and secure. Many of us would like to have the good old $compile back from the AngularJS days, and this proposal tries to strike the balance between the flexibility of $compile and security and performance of AOT compilation.

@IgorMinar
Copy link
Member Author

@IgorMinar IgorMinar commented Aug 12, 2021

I added a new section to the issue above describing the trade offs of this proposal.

@IgorMinar
Copy link
Member Author

@IgorMinar IgorMinar commented Aug 12, 2021

@petebacondarwin kindly pointed out that my example TS code was not syntactically valid due to typos, misaligned braces, and most problematically the use of if within an array literal. I updated the code snippet to fix these. Thanks Pete!

@jnizet
Copy link
Contributor

@jnizet jnizet commented Aug 12, 2021

@IgorMinar the first snippet has a const named myCustomView, created by calling the function createView. But the second snippet doesn't define a createView function. Instead it defines a myCustomView function calling another (framework-provided) createView function. I was a bit troubled when trying to understand it. Some renaming would make the code corrrect, and thus easier to understand IMHO. I guess some await and asyncare missing too

  protected async renderDynamically() {
    const myCustomView = await createMyCustomView('./myBlue.component',
      {userName: 'Kahless', myRoutePath: ['my-route'], showDetails: true});

    // appends the view
    viewContainerRef.appendEmbeddedView(myCustomView);
  }
  
  async function createMyCustomView(blueComponentESModulePath: string, myContext: {[key: string]: any}) {
    const blueComponent = (await import(blueComponentESModulePath)).default;

    // this is where the magic happens
    return createView(...);
  }
@IgorMinar
Copy link
Member Author

@IgorMinar IgorMinar commented Aug 12, 2021

@jnizet thanks you so much for pointing this out! I think I updated the code with all of your suggestions.

I expected the code to have some minor syntax flaws when I was typing it out in the github issue editor, but I see that the next time I do something like this I should instead create a stackblitz that compiles but doesn't run.

@sg-gdarnell
Copy link

@sg-gdarnell sg-gdarnell commented Aug 12, 2021

I love this, it's basically what I always hoped Ivy would enable.

However, I'm confused about how updates will work. I realize the syntax is nowhere close to final, but I'll use it as a reference for discussion.

In the example, if you used plain JavaScript expressions (if, switch, for, etc.) in your createView() call instead of NgIf, NgSwitch, NgFor, etc., wouldn't you have to destroy and recreate your view each time your context changed (expensive)? If you limited yourself to using the built-in structural directives instead then you could simply pass a new context to your embedded view, but I can imagine that devs familiar with React might assume that plain expressions would "just work" as they do in React, which might be a pain point.

If we do need to limit ourselves to using structural directives over plain expressions then it may be appropriate to provide some helper functions for the built-in stuff, so maybe instead of this:

template([
       text("some details")
       ], {directives: [
         directive(NgIf, {binding: () => myContext.showDetails})
       ]})

You could do:

ngIfTemplate(
  [text("some details")],
  () => myContext.showDetails
)

Of course, the community could build helper functions like that themselves. My main concern is that the way expressions restrict the ability to update a view may trip people up.

Thanks for sharing this openly! I'm excited for all the things a more dynamic Angular could bring.

@IgorMinar
Copy link
Member Author

@IgorMinar IgorMinar commented Aug 12, 2021

@sg-gdarnell great points, thanks for bringing them up!

The important thing to note is that the current design doesn't rely on reevaluating the createMyCustomView function on each change detection tick. Instead, this function executes just ones. If you need the view to have conditional parts that respond to model changes and change-detection after the view has been created and attached, then you'd have to use NgIf, NgForOf, etc.

This brings me to the second part of your suggestion, it absolutely makes sense to add native support for NgIf and friends using an API like ngIfTemplate. I'll think about it a bit more, and update the proposal to include this suggestion.

@manfredsteyer
Copy link
Contributor

@manfredsteyer manfredsteyer commented Aug 12, 2021

Awesome proposal! Thanks! (+ thanks for the hidden Star Trek reference)

@IgorMinar
Copy link
Member Author

@IgorMinar IgorMinar commented Aug 12, 2021

(+ thanks for the hidden Star Trek reference)

@manfredsteyer wow, that was fast! I thought that it would take longer for someone to connect the dots. #highFive err... #elbowBump

@SanderElias
Copy link
Member

@SanderElias SanderElias commented Aug 13, 2021

@IgorMinar Thanks for the additions and the answers.
It is good to know that template compilation is out of the scope of this proposal. Makes a lot of sense.

After having seen enough examples of how compile$ was used, I was always glad it wasn't available in Angular 😉 (I'm not afraid to say, that a few of those were of my own hand tho 😁)
And I will not shed any tears on seeing the JIT compiler go when there is an alternative like this is in place.

Now a new question arises:

      // you can use javascript expressions instead of `NgIf` to conditionally add view "nodes":
      (Math.random() > 0.5) && text(" and followed by another component"),

That will put false into the array literal. No problem there. But it brings me to the question.
What will be rendered into the view? Will the createView function just filter out anything that's not a known-thing™? (Or a function that returns a known-thing™?)
Or will it just ignore falsy values, and render the rest as text (I hope this is not going to happen!)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked pull requests

Successfully merging a pull request may close this issue.

None yet
6 participants