-
Notifications
You must be signed in to change notification settings - Fork 601
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
[Proposal] Provider DSL #3361
Comments
I love it. I think this is very straight forward and intuitive just by scanning the code which will improve the onboarding experience. |
Thanks for putting this together @EisenbergEffect! Overall it looks great and super extensible. A case I don't see addressed is inter-component dependencies. Lets say that component html`
<template>
<fast-b>foobar</fast-b>
</template>
` An author would need to know the implementation details of Is this a case you've thought about? One potential solution that I see: Expose the configuration from the provider?If the configuration is exposed by the provider, each component could ensure any dependent component is registered. If it's not, then it could register it: import { fastB } from "@microsoft/fast-components";
connectedCallback() {
super.connectedCallback();
this.$fastProvider.configuration.register(
fastB() // Wouldn't want this to clobber any author-defined registration config
)
} Perhaps there is even a way to collect the tag-name from the provider for any dependent components used: // This is mostly to illustrate the idea, not the implementation. I recall some issue about data-derived tag names...
import { fastB } from "@microsoft/fast-components";
const fastATemplate = html`
<template>
<${x => x.$fastProvider.configuration.getTagNameFor(fastB)}>
foobar
</${x => x.$fastProvider.configuration.getTagNameFor(fastB)}>
</template>
`
|
@nicholasrice I think we can handle the dependency issue without too much trouble. We could add that to the registration API so that component authors can declare that and we can auto-register those if not explicitly asked for (or maybe in the same way we can associate behaviors with css, we could associate elements with templates?). The real trick is if the element names or prefixes are changed for a dependent component. That is tricky to handle without custom element registries, which we could use to ensure that the elements in the private shadow dom are always the ones we expect. I can imagine some tricksy ways to work around that. It's not as simple as the pseudo-code you have above, due to the way the template engine works, but we could come up with something. It will complicate this work though and might require some additional changes in fast-element to make happen (e.g. re-writing element names in templates during compilation or something like that). Do we need to take that on right now or could we push it out to later? |
An idea based on associating elements with templates: const template = html`
<foo-bar></foo-bar>
`.withRegistry({
'foo-bar': FooBar
}); That wouldn't be needed for most templates, only those in component libraries that depend on other components. From this info, we can reverse map the element name used in the template to the actual name at runtime based on the provider configuration. I think this should also fit well with custom element registries, as this basically would define what should be in the registry associated with the shadow dom. For now, we can just handle this in our compiler. |
I think I like the yea it's a bummer we don't have custom registries for this! I'd be okay with pushing the tag-resolution feature out if it makes it easier. This only concerns custom elements in shadow-dom so the tag-name doesn't really matter as long as it's the right element implementation, right? |
The first: we could teach the template compiler to remap the names I think. I'd prefer to delay doing this particular piece until we have someone come with a situation where we need it though, if we can. If we had more time to think about it, we could maybe come up with a way to do it as a custom registry polyfill of sorts. |
Another thought I want to capture here - there are cases where certain semantic elements are providers for CSS custom properties and should allow registration of property resolves. This includes components like cards, dialogs, flyouts, that have backgrounds that can (and often are) distinguished from the rest of the page. |
Dropping a note here that we want to see if we can lazily create a design system if one doesn't exist. The obvious problem is that in order to do that...we need to statically link everything into the lazy initialization code, which will prevent tree shaking. My thinking is that we have a function that is exported that does this. That could then be called in the single file script build or by a partner that wants to bundle things up. Another note is that we want to enable customization of the element prefix even when a single file build is used. We could enable this with a <script type="module" src="..." data-prefix="fast"></script> |
A quick update on this work... As part of the exploration, we decided to try directly incorporating a dependency injection system. The results of the exploration were very positive. So, we've reworked the entire provider plan on top of a core DI system. The linked PR contains the code for the DI, FoundationElement, and a configuration DSL for basic configuration of elements, templates, and styles. No components are yet swapped to the new system, as that would be a breaking change. We hope to merge the new system soon and then continue to evolve it so that it supports all aspects of the design system. Once that is in place, we'll migrate the components and publish a major version number bump. |
Closing this as "done" since the infrastructure for almost everything described is now in |
Problem
Having gone through the process of implementing a significant number of components for our library, writing documentation, and working with a 3rd party to create a tutorial for FAST, a number of "rough edges" with some of our approaches have popped up:
Using multiple design systems on the same page is challenging
Today, if you import from both
fast-components
andfast-components-msft
you will experience a runtime error. This is due to the fact that both libraries register web components under the same element names. We did this to enable easily swapping the design system, under the goal of having a single set of components with multiple design systems. However, under the current implementation, the template and styles for a component are still statically linked to the custom element definition (and name) itself, preventing us from having both design systems present.Element registration is a side-effect of importing
The underlying call to the
customElements.define
API in the platform is not predictable. It happens as a result of importing a component because the decorator on our components calls the define API during module evaluation. While this is a desirable convenience when building site or app-specific components, it's a problem for customers who want to consume a 3rd party library of components (e.g. fast-components).There's no way to disambiguate element definitions
Due to the fact that element registration is a side-effect of importing, there's no opportunity to change the registered element names. This means there's no way to disambiguate our elements from another library that might happen to use the same names. Furthermore, there's no way to disambiguate between multiple design systems built on our same foundation, as described above in the issue on "Using multiple design systems on the same page is challenging".
We have an awkward need for seemingly inert expressions just to prevent tree shaking
Also due to the fact that element registration is a side-effect of importing, bundlers get confused about what the actual dependencies are and will tree-shake away all our components unless they are referenced directly in code. As a result, we're forced to do things like this:
Our design systems add an additional layer of inheritance just to associate styles
As mentioned above, we currently statically associate styles and templates with the element definition of an element. We're using the
customElement
decorator to do this, which requires a unique class. However, FASTElement has an imperative API that would allow us to make the association without needing the extra layer of inheritance. As a result of our current approach, we also end up with a number of classes in our API docs that have no API. They are just there to inherit from a base class so that styles can be associated.There's no way to dynamically change an element's template or styles (or shadow dom mode)
Our API allows us to add or remove additional styles and behaviors, but not change out the base styles dynamically. Templates and styles are statically bound to the component and unchangeable. This prevents several interesting scenarios such as:
Proposed Solution
To address the above issues, and open up additional possibilities, I'm proposing a number of changes across our packages. Below I'll address each package with some basic code samples and try to explain how all the pieces fit together to address the above issues and open up new possibilities.
Changes in fast-element
The main changes in
fast-element
are as follows:Underlying
FASTElement
is theController
class, which controls template instantiation, binding, style adoption, lifecycle callbacks, attribute change notifications, dynamic behaviors and more. Presently, when theController
is instantiated for an element, in its constructor, it immediately gets the template and styles from the element definition and applies them. Because theconnectedCallback
of an element happens before the first paint of a custom element, it's safe to delay the application of template and styles to this stage.In addition to delaying the application of template and styles, I propose adding some indirection so that that the
Controller
calls methods on the element, asking it to resolve its template and styles. By default, the base implementation would retrieve the template and styles from the element definition, but because these methods now exist, sub-classes can override this behavior to provide custom resolution logic. This is a critical extension point leveraged below with the introduction ofFoundationElement
.Here's some psuedo-code that demonstrates the changes I propose to apply:
Changes in fast-foundation
The changes in
fast-foundation
build on the new extension points added infast-element
as well as introduce additional patterns for design systems. In particular, a DSL for design system creation is introduced. This system underlying the DSL also provides primitives upon which features such as advanced dependency injection could be built as an optional plugin. The main changes are as follows:FoundationElement
, derived fromFASTElement
which will serve as a base class for elements with design system "smarts" as well as template and css dynamism.Provider
The first step is to tap into the new capabilities of
FASTElement
. To do this, we'll introduce a new class calledFoundationElement
. Below is pseudo-code demonstrating what it adds on top ofFASTElement
:The first things that are added are two new
@observable
properties:template
andcss
. These properties allow for anyFoundationElement
instance to have its template and base styles changed at any time. With the delay in template instantiation introduced inFASTElement
this means that these values can be set through the template engine's binding system without incurring any cost due to accidental pre-rendering of stale templates.The nex thing to note is that the
connectedCallback
is enhanced so that it resolves a provider. More on this is described below. For the purpose of this part of the explanation, the provider is an object that can provide templates and styles for custom elements. It is resolved through the DOM hierarchy.Finally, the new methods
resolveTemplate
andresolveStyles
which were introduced inFASTElement
are overridden so that they access thetemplate
andcss
properties first, then the provider, in order to obtain templates and styles, before falling back to the base implementation.The additions introduced in
FoundationElement
enable decoupling the element defined with the platform from its template and styles, which can now be dynamically resolved through observable properties or a parent DOM node (the provider).Moving on to the
Provider
, it is a customHTMLElement
with an API something like this:The provider is a custom element that provides various data and services to descendent nodes. This can include design system properties, a dependency injection container, or any number of capabilities. For the purpose of this part of the explanation, we'll say that it minimally provides the ability to resolve templates and styles for an element. It also has a reference to its parent provider, as certain requests may be forwarded up the DOM tree to parent providers.
The next question to address resolves around the definition of the provider itself. For this purpose, I propose a DSL. The DSL starts with a
Configuration
object that is used to define the various aspects of a design system, in terms of our primitives. This includes templates, styles, design system properties, etc. It also provides a way to package up "registries" that encapsulate reusable configuration settings that can be applied to a provider. Finally, it provides a way to instantiate aProvider
based on the definition contained in theConfiguration
object. The result of thedefineProvider
operation is that a new custom HTML element is defined, which can then be used in the DOM to provide all data and services needed. Here's some pseudo-code that shows what theConfiguration
andRegistry
APIs might look like:Take note of how the
Configuration.forComponent(options)
method takes a set of custom element metadata and produces a function that can create aRegistry
for that element. How these APIs are used in practice will be come clearer below when we look at howfast-components
would change to use them.Example changes in fast-components
With the above primitives, we can now define a design system more concretely in our code. To do so, we would inherit from the
Configuration
object. In the constructor, we would call APIs to define design system properties, attributes, styles and behaviors that are required statically by the design system.We would then create registry factories for each component that we want the design system to support. Here are some examples of what that would look like:
These technique allows each component's registration with the design system to be modular, maintaining the ability to tree shake unused elements. Additional registries can be created for other shakeable items, such as behaviors, appearance capabilities, etc.
Now, let's take a look at how a customer uses this:
Example usage
The code above imports the design system configuration, along with the modular pieces that the customer wishes to use. Each piece is registered with the configuration and then the provider is defined.
When the provider is defined, it first creates a new custom element for the provider, defined with the platform, dynamically built from all the configuration settings. It then walks through the components and registers them one by one, along with their templates and styles.
In HTML, usage looks like this:
The
template
andstyles
forfast-element
are derived from the singularfast-provider
that is defined, while the design system properties are inherited through the DOM based on the closest instance offast-provider
.We can also now define multiple providers for different design systems and use them together on the same page, without needing different element names (except for the provider):
Here's some HTML using this:
Note that we can use the same
fast-anchor
element because when it has a different providers, its template and styles can then be derived based on which providers it is a descendent of.Bigger Differences between Design Systems
Let's take the case of
badge
where the-msft
version differs not only in styles, but also adds additional properties to thebadge
element. How do we handle this? There are several options, one way is to handle it the same way that we do today. Here's some code for that:How would the provider system work in this case?
fast-badge
with the above functionality.While using inheritance to address this issue of variance it possible as shown above, there are other options which do not require inheritance as well. We should look at each component separately to see whether we truly need to introduce new behavior that cannot be added through any of the compositional affordances of
FASTElement
orFASTFoundation
. For example, something like a Material Design ripple effect can actually be added through aBehavior
associated with a style. Let's take a look at the case ofbadge
above. Could that be implemented in an alternative way? Based on the current definition, yes. In fact, we don't need to add theappearance
attribute at all because we don't have any real behavior that is being affected by that. It's only adding classes to the host for the purpose of styling. However, we can add host attribute selectors to handle this directly in CSS. For example, instead of this:We could do this:
In fact, this is a "cheaper" implementation as it does not require all the platform machinery needed to define custom attributes, add their callbacks, and update host classes.
Regardless, we have a wide variety of techniques available to us to differentiate design-system specific variants of components. With the changes proposed here, we can actually achieve many more variations through runtime context, without requiring static solutions like inheritance and decorators (though these are always available to us if the situation requires them).
Other examples
Per Instance Style/Template Customization
How Fluent UI Could Build on FAST
Could our React wrappers hook into this?
Advanced Dependency Injection
The text was updated successfully, but these errors were encountered: