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

[Blazor] Dynamic components design proposal #32352

Closed
19 tasks
javiercn opened this issue May 3, 2021 · 0 comments
Closed
19 tasks

[Blazor] Dynamic components design proposal #32352

javiercn opened this issue May 3, 2021 · 0 comments
Assignees
Labels
area-blazor Includes: Blazor, Razor Components design-proposal This issue represents a design proposal for a different issue, linked in the description Done This issue has been fixed
Milestone

Comments

@javiercn
Copy link
Member

javiercn commented May 3, 2021

Summary

We want to enable the capability of interacting with Blazor components from JavaScript as well as support interacting with other javascript frameworks that might be running on the page. To support this, we will enable creating Blazor components from JavaScript and attach those components to specific DOM elements. We will support passing parameters to those components via JavaScript as well as removing those components from the DOM once they are no longer needed.

Motivation

We want to support integrating Blazor with other component frameworks and just be another "JavaScript" framework in general that developers can integrate with their applications. That means developers should be able to have an existing application like an MVC + jquery, or an existing SPA application (like React, Angular, Vue, Svelte, etc.), be capable to integrate Blazor components dynamically whenever they need it and interact with them from within the Browser.

Goals

  • Support dynamically rendering components from JavaScript into the DOM, not just during startup.
  • Interact with any other JavaScript framework/technology running on the page.
  • Enable building component libraries that can offer reusable Blazor components for consumption from other applications.
  • Enable multi-framework teams to leverage Blazor.

Non-goals

TBD

Scenarios

  • As a customer I want to render a Blazor component as the result of the user interacting with the page, for example when the user clicks a button.
    • I can have a completely static page for the most part, and when I require the user to input some data, I can display a modal dialog with a Blazor powered form handle validation and submision of the form to an API.
  • As a customer I want to integrate a Blazor component within my existing application and interact with it from JavaScript.
    • I can have an existing SPA application and I want to use a new reporting component to display detailed information about my business. The logic for controling some aspects of the data is handled externally (filters, etc).
  • As a customer I want to offer my controls as a reusable library that third party vendors can load on to their page and use.

Risks

  • Integration with other frameworks might not be possible/smooth in all cases.
  • This is a large area so there are many unknowns that we'll have to deal with as we make progress.
  • There are security considerations to be had when using this from Blazor server.
    • Type deserialization
    • Policy to avoid an unlimited number of components per circuit.

Interaction with other parts of the framework

TBD

Detailed design

This is a very large area, so we will divide the design in phases, each of which will provide additional capabilities and deliver incremental value to customers.

Phase one - Enable interaction with Blazor from JavaScript

This phase covers the minimum work strictly required to unblock customers to interact with Blazor from JavaScript, which then they can build additional experiences on top.

Creating components from JavaScript

We will need to offer new APIs to create components with JavaScript. These APIs will be exposed from the global Blazor object, and will return an object developers can use to further interact with the component.

var component = Blazor.renderComponent(<<some-sort-of-descriptor>>, htmlElement, initialParameters);

Removing/destroying components from JavaScript

We will need to offer some APIs to destroy existing components and free up resources once the components are no longer being used. These APIs can be exposed directly through the proxy returned by the renderComponent method. For example:

component.dispose()

We will name this method dispose since it is a convention known from .NET developers. Disposing a component this way will free up all .NET resources in use by the component as well as offer an opportunity for the component and its descendants to free up all DOM resources/state they were using via the standard dispose mechanism.

Pass parameters to the component

We will need to pass parameters from JavaScript to the component during the initial render as well as during successive renders. In general, there is no strongly typed contract for passing in parameters to a component. Since a component receives a ParameterView instance which is comparable to a list of IEnumerable<KeyValuePair<string,object>> where the key is the name of the parameter and the value is represented as an object. However, during deserialization we need to know the types of the parameters or otherwise we won't be able to correctly deserialize them. In the case of server-side Blazor we also want to make sure we don't deserialize any unknown parameters and create the possibility of a type explosion.

Based on the points made above, we need to require some way of registering what parameters a "dynamic" root component is willing to accept. When a component receives parameters from JavaScript there are four cases to consider:

  • The parameter has a "simple" type that we can "convert" without loosing precission:
    • This normally means primitive types like strings, numbers, booleans, etc.
    • Numbers can be generally converted to int/long when they don't have decimals and to doubles when they do; booleans can be converted to true and false; strings don't need conversion.
  • "Complex" parameters are those that are represented as JavaScript objects and for which there is no implicit mapping to a C# type (other than JsonElement if we are using STJ for deserialization). In addition to that, "primitive" types are always passed in "by value", while for complex types we need to decide whether they should be passed in by value or by reference. There are several ways to solve this problem:
    • Implicit mappings for complex parameters are not supported and we will throw an exception.
    • Implicit mappings for complex parameters will be supported as long as those parameters are JsObjectReferences.
    • Implicit mappings for complex parameters will have "reference" semantics and will be automatically converted to JsObjectReference instances.
  • "Callable" (function) parameters present an additional challenge, since the component that receives them should be able to invoke them, pass arguments to the invocation and receive a result back.
    • We currently don't have a way to do this, since there is no type to represent a JavaScript function via JS interop. Based on this there are several options:
      • Functions are not supported and developers must write a wrapper object to expose them to their component.
      • Functions are represented as JSObjectReference and can be invoked via JS interop (something like InvokeAsync("", ..args)).
      • We extend JS interop with a new type JSFunction that represents a JavaScript function that is directly invokable jsFunc.InvokeAsync(...args)
  • "Registered parameters" offer a solution for many of the questions we described above. By forcing the developer to be explicit about the number and types of parameters its component its willing to accept, we can make better decissions about how to map incoming parameters from JavaScript to C#.
    • This doesn't necessarily mean a developer must map all parameters explicitly in code. We can leverage existing attributes like Parameter, CascadingParameter, etc. to automatically detect the parameters a component is willing to accept.
      • Developers can register components that can be created from JavaScript during startup.
      • We can scan the component type for Parameter and CascadingParameter attributes.
      • We will map "primitive" and "complex" parameters to their C# counterpart.
      • We will map "function-like" parameters to delegates that will invoke the passed in JS function/object.
      • "Function-like" parameter types (Delegates, EventCallback) present special challenges of their own. These types of parameters enable integration from Blazor with the JavaScript world in a way that makes JSInterop "implicit". Given that, they have the same limitations we impose in other JS interop scenarios.
        • No sync interop (meaning non-task returning delegates are not supported)
        • Same security considerations as other JS interop scenarios in Blazor server.
      • We can treat CascadingParameter just as a regular Parameter attribute since there is no cascading value provider wrapping it. Its valuable to make it work because that way avoids having to wrap those components just for using them as root components.
    • We can allow developers to tweak the registrations after the discovery step we perform to create the initial mapping. This enables developers to retain full control over the JS to .NET mapping that is independent of the base class or attributes Blazor provides.
      • This is important since our "opinions" can change over time as we evolve the framework.
    • The fact that we register components for "accuracy" doesn't mean we can't still maintain an implicit mapping for unknown parameters.
      • Direct parameters are mapped to their explicitly registered types.
      • "Primitive" parameters are mapped to their best match.
      • "Complex" parameters are mapped to JSObjectReferences
      • "Functions" are mapped to "JSObjectReference/JSFunction".

The gauge here is how much we want to make these types of components work in general situations without requiring explicit changes to the component definition or requiring additional steps to register the component parameters.

Pass information out from the component to JavaScript

There are already several ways to do this, for example doing JS interop from the component. However, is a common pattern that elements/component can communicate with other components via "event-like" interfaces. In this sense there are several options:

  • Explicit JS interop via static functions, JSObjectReference, etc.
  • Existing "function/event" based types like Action, Function, EventCallback.
  • Custom events (this is specific to HTML elements) which allow an event to be raised through the DOM tree in the same way as a regular event, like a click, change, keydown, etc event.

The first two options are already supported by Blazor, the third option is not, however it is a common way to interact with elements on a page, so it is interesting we consider supporting it.

Expose an "interface" from the component to the "JavaScript" world that can be consumed through "idiomatic" javascript code.

Given that these components will have a higher level of interaction with JavaScript, it might make sense to offer an interface that can naturally be directly consumed via JavaScript. For example, if we are building a "form" component in Blazor, it might make sense that it offers a set of methods to interact with the component through JavaScript, like validate, reset, submit, etc.

We can consider a new feature that components can implement to offer this "stream-lined" interface to the JavaScript world. We can mark the component somehow to "expose" some methods directly on the JS proxy created as a result of rendering a new instance of the component and we can use that gesture to also make the component re-render automatically after one of those methods is called in the same way it happens when a component handles an event.

Going back to the example above, it means that when a component receives a call to validate, reset or submit it can avoid an explicit call to StateHasChanged.

Content inside existing elements

So far we've described components that don't have "content" inside them. However there can be situations where it is useful to have content within them, for example if the layout for a page/section is done in Blazor and the content is done with a different framework.

Should Blazor offer a way to represent these inner nodes and pass those to the component upon initialization?

An HTML element node could be abstracted into a RenderFragment and rendered with the same syntax as one, with the exception that the content is already provided by someone else. There would need to be a way to represent these special "markup" primitives in a render batch and additional handling on the browser renderer to find the given html element and insert it in the dom at the right position.

This opens the door to more complex interactions between frameworks than just "islands" within frameworks.

Phase two - Integration with other frameworks

This phase focuses on features and fixes to ensure it is possible to integrate properly with other UI frameworks. We need to establish the requirements we can impose on other frameworks to ensure we can integrate with them.

For example, when we render a component into a DOM element it is fundamental that Blazor has full control over the rendering and updates of that element and that the presence of additional children created by Blazor doesn't interfere with the other framework rendering process as well as updates from the other framework don't interfere with the rendered nodes for the element.

In this sense, any framework that wants to be supported here needs to be able to:

  • Treat the element node that will host the Blazor component as an "opaque" container, which means it won't interact with the node contents.
  • Retain the node that hosts the Blazor component across UI updates, which means that if an update happens after the Blazor component has been rendered into a node, it won't destroy the existing node and replace with a new instance and instead will just reuse the existing node and just update any attributes if necessary.
  • Expose a valid reference to the HTML element after it has been rendered into the DOM so that developers can use Blazor to attach the component to it.

At this stage we will also ensure that Blazor is also able to integrate with popular web standards in this space (web components related standards) like custom elements, shadow DOM or slotted content.

As a result, we should be able to:

  • Register a custom HTML element and render a Blazor component within it.
  • Render a Blazor component inside a shadow root.
  • Respect slotted content inside the element where we are rendering the component.

By supporting the standards associated with web components we can make sure that developers can author a set of components that cleanly integrate with any other framework.

Phase three - Smooth out the experience

This is additional work that we can choose to do if we have time and that will cover the remaining experience gaps around authoring component libraries that can be consumed by other frameworks.

  • A potential new template/option to generate a "Blazor web component library" with ready to use components.
  • Automatic registration of web components as custom elements, with rendering inside the shadow DOM.

Plan of work

Given the large amount of work in this space we will break down the work into smaller pieces of work that can be implemented in smaller chunks of work.

  • Render component from JavaScript in Blazor Webassembly.
  • Render component from JavaScript in Blazor Server.
  • Render component from JavaScript in Blazor Desktop.
  • Remove component from JavaScript in Blazor Webassembly.
  • Remove component from JavaScript in Blazor Server.
  • Remove component from JavaScript in Blazor Desktop.
  • Pass primitive parameters to a component from JavaScript in Blazor Webassembly.
  • Pass primitive parameters to a component from JavaScript in Blazor Server.
  • Pass primitive parameters to a component from JavaScript in Blazor Desktop.
  • Pass complex parameters to a component from JavaScript.
  • Pass function parameters to a component from JavaScript.
  • Expose events from a component to JavaScript.
  • **Expose an interface from a component to JavaScript.
  • Can render a component from another SPA framework like React.
  • Can pass parameters to a component from another SPA framework like React.
  • Can destroy the component when the element is destroyed by another SPA framework like React.
  • Supports rendering a Blazor component as a custom element.
  • Supports rendering a Blazor component inside a shadow root.
  • Supports rendering a Blazor component inside an element with slotted content.

Drawbacks

Considered alternatives

Open questions

@javiercn javiercn added design-proposal This issue represents a design proposal for a different issue, linked in the description area-blazor Includes: Blazor, Razor Components labels May 3, 2021
@mkArtakMSFT mkArtakMSFT added this to the 6.0-preview5 milestone May 3, 2021
@mkArtakMSFT mkArtakMSFT modified the milestones: 6.0-preview5, 6.0-preview6 Jun 5, 2021
@javiercn javiercn modified the milestones: 6.0-preview6, 6.0-preview7 Jun 14, 2021
@ghost ghost added Done This issue has been fixed and removed Working labels Jul 26, 2021
@ghost ghost locked as resolved and limited conversation to collaborators Aug 25, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-blazor Includes: Blazor, Razor Components design-proposal This issue represents a design proposal for a different issue, linked in the description Done This issue has been fixed
Projects
None yet
Development

No branches or pull requests

2 participants