Status: unknown

Champion: alejandro-sf

Revision: latest

RFC created: unknown

Last updated: unknown

This RFC is under review in PR (#48)

RFC: Dynamic Component Configuration

Summary

This RFC introduces a set of principles and invariants required to dynamically configure a lazy loaded component

Terminology

Interface Example

<template>
   <c-my-cmp lwc:bind={configurations}></c-my-cmp>
</template>
import { LightningElement, api } from "lwc";

export default class LazyCmp extends LightningElement {
	@api attributes;
	@api properties;
	@api handlers;

	get configurations() {
		return {
			attributes: this.attributes,
			properties: this.properties,
			eventHandlers: this.handlers,
		}
	}
}

Working Example

This example shows how we render dynamic analytics dashboard with one type of chart. The component has dashboard data that it will assign to the chart item and it renders to the screen.

<template>
	<c-lazy-cmp lwc:bind={chartItem} lwc:dynamic={ctor}></c-lazy-cmp>
</template>
import { LightningElement, api } from "lwc";

export default class LazyCmp extends LightningElement {
	@api dashboardId;
	@api dashboardData;

	get chartItem() {
		return this.chartItem;
	}

	@wire(myAdapter, { id: 'someDashboardId' }) {
		chartDefinitions({ data, error }) {
			let chart = data.chart;

			// Populate 'ctor'
			// For simplicity assume they've been loaded async elsewhere
			this.ctor = chart.ctor;
			let attributes = {};
			let props = {};
			let eventHandlers = {};
			
			// Populate 'attributes', change style based on some config data
			attributes.style = {
				classname: chart.type === 'donut' ? 'chart-small' : 'chart-medium'
			};

			// Populate 'props', set the dimensions into charts that support them.
			if (chart.supportsDimensions) {
				props.dimensions = dashboardData.dimensions;
			}

			// Populate 'events', bind the event handler if it supports that
			if (chart.canFilter) {
				eventHandlers.onfilter = this.handleFilter;
			}

			chartItem =  {
				attributes,
				properties: props,
				events: eventHandlers
			}
		}
	}
}

Motivation

Before this proposal, dynamic component creation was done using the lwc:dynamic directive. This directive required a component author to know component configuration details like the properties and event names at design time, which breaks down for use cases that need true dynamic component creation. For instance, in the case of property values or event handlers one would have to keep a reference to the element and set the information retrieved at runtime manually.

Also, when connetedCallback called on child and the component has been inserted into DOM, but its properties/attributes/eventHandlers have not been initiated, we also need to wait for another cycle to get them rendered with the correct properties. This breaks the framework's life cycle.

export default class LazyCmp extends LightningElement {
	element;

	@api moduleName;

	connectedCallback() {
		const ctor = await import(this.moduleName);
		this.element = createComponent('c-lazy-cmp', { is: ctor.default });
		// element must be manually inserted into the dom
		this.template.querySelector('div').appendChild(this.element)
	}

	@api
	set properties(newProperties) {
		this.props = newProperties;
		Object.assign(this.element, this.props);
	}

	get properties() { return this.props; }
}

A new mechanism must be introduced that will allow component authors to configure their components with information retrieved at runtime without relying on maintaining element references or manually manipulating the DOM.

How other frameworks are approaching this issue

Vue.js

In the wrapper component(currentComponent) level, they do "bind" to props, and they return the corresponding props for different child level components(e.g. myComponent). In this way, they don't need to rerender to get the component with the correct props. But different child components' props are exposed at wrapper component level.

example:

<template>
	<c-cmp :is="currentComponent" v-bind="currentProperties"></c-cmp>
</template>
data: function () {
  return {
    currentComponent: 'myComponent',
  }
},
computed: {
  currentProperties: function() {
    if (this.currentComponent === 'myComponent') {
      return { foo: 'bar' }
    }
  }
}

Our Proposal

The proposal involves adding a new directive that captures the relevant information to configure a web component at runtime. The assumption is that this directive will be used in cases where the component constructor is unknown until runtime and zero or more of the following are unknown until runtime: properties, attributes, or event handlers.

The directive lwc:bind can retrieve component configuration data via a JavaScript object with a pre-defined shape. Constructor is the only required property in the configuration data.

Design Decisions Based On Our Proposal

General Invariants

Drawbacks

Identifying the dependencies for static analysis could be difficult especially if the configurations are not derived from @api properties or statically analyzable strings. Additionally, this proposal would enable true dynamic component creation which has been abused in the past - proper guardrails would need to be implemented to prevent improper use of this pattern.

Adoption Strategy

This feature will be available in Salesforce Lightning Platform and LWR to certain teams depending on their use cases

Unresolved Questions

Appendix for more complexed example

This example shows a watered-down example of rendering a dynamic analytics dashboard. The component has some dashboard data that it will assign to the chart items it renders to the screen.

<template>
   <template for:each={chartItems} for:item="chartItem">
      <c-lazy-cmp lwc:bind={chartItem} lwc:dynamic={ctor}></c-lazy-cmp>
   </template>
</template>
import { LightningElement, api } from "lwc";

export default class LazyCmp extends LightningElement {
	@api dashboardId;
	@api dashboardData;
	@api colorPalette;
	@api defaultKpiBreaks;

	chartItems = [];

	@wire(myAdapter, { id: 'someDashboardId' }) {
		chartDefinitions({ data, error }) {
			chartItems = data.charts.map((chart) => {
				// Populate 'ctor'
				// For simplicity assume they've been loaded async elsewhere
				this.ctor = chart.ctor;
				let props = {};
				let attributes = {};
				let eventHandlers = {};

				// Populate 'attributes'
				// For example, change style based on some config data and pass
				// in a unique id
				attributes.style = {
					classname: chart.type === 'donut' ? 'chart-small' : 'chart-medium'
				};
				attributes.id = `${dashboardId}-${chart.id}`;

				// Populate 'props'
				// Set the dimensions into charts that support them.
				// For example, a KPI doesn't have a dimensions prop
				// and would throw if you tried to supply one:
				if (chart.supportsDimensions) {
					props.dimensions = dashboardData.dimensions;
				}
				// Some chart items, like a dimension filter card,
				// might not support measures as inputs:
				if (chart.supportsMeasures) {
					props.measures = dashboardData.measures
				}
				// Some charts support colors in different ways:
				if (chart.type === 'kpi' || chart.type === 'gauge') {
					props.breakpoints = chart.customBreaks || this.defaultKpiBreaks;
				} else if (chart.supportsColorPalette) {
					props.colorPalette = this.colorPalette;
				}
				// Populate 'events'
				if (chart.canFilter) {
					eventHandlers.onfilter = this.handleFilter;
				}
				if (chart.canSort) {
					eventHandlers.onsort = this.handleSort;
				}
				return {
					attributes,
					properties: props,
					events: eventHandlers
				}
			});
		}
	}
}
undefined