This document describes the overall code architecture of the open-source Lightning Web Components (LWC) framework, i.e. the repository you are looking at right now.
The idea behind an ARCHITECTURE.md
file can be found in this post.
The LWC codebase is a TypeScript/JavaScript monorepo. The convention for naming packages is packages/@lwc/*
.
For example, the template compiler is located at packages/@lwc/template-compiler
and will be published to npm as @lwc/template-compiler
.
The one exception is the lwc
package, which is located at packages/lwc
. This is a "barrel package" that merely re-exports other packages.1
Also note that some private packages are included in packages/@lwc/*
. This is not for publishing to npm under the @lwc
namespace, but instead to avoid dependency confusion attacks.
The LWC codebase is broadly split into two categories:
- Compiler: This runs in Node.js at component compilation time.
- Runtime: This runs in the browser at component runtime (or on the server in Node.js in the case of SSR).
Besides these two categories, there are also some shared packages and polyfills, but let's start with these two groups.
At a high level, the @lwc/compiler
calls into three other packages to process HTML, CSS, and JS2 files respectively:
graph TD
compiler[@lwc/compiler];
templatecompiler[@lwc/template-compiler];
babelplugincomponent[@lwc/babel-plugin-component];
stylecompiler[@lwc/style-compiler];
compiler-->|HTML|templatecompiler;
compiler-->|CSS|stylecompiler;
compiler-->|JS|babelplugincomponent;
A typical LWC component is composed of *.html
, *.css
, and *.js
files, so one compiler package handles each type of file. In all three cases, the output is JavaScript.
The following core parsers are used for each file type:
We can complicate the diagram a bit more by including @lwc/rollup-plugin
3, which manages the Rollup integration:
graph TD
rollupplugin[@lwc/rollup-plugin];
compiler[@lwc/compiler];
templatecompiler[@lwc/template-compiler];
babelplugincomponent[@lwc/babel-plugin-component];
stylecompiler[@lwc/style-compiler];
rollupplugin-->compiler;
compiler-->|HTML|templatecompiler;
compiler-->|CSS|stylecompiler;
compiler-->|JS|babelplugincomponent;
This also gets more complex once we include the SSR compiler:
graph TD
rollupplugin[@lwc/rollup-plugin];
compiler[@lwc/compiler];
templatecompiler[@lwc/template-compiler];
babelplugincomponent[@lwc/babel-plugin-component];
stylecompiler[@lwc/style-compiler];
ssrcompiler[@lwc/ssr-compiler];
rollupplugin-->compiler;
compiler-->|HTML|templatecompiler;
compiler-->|CSS|stylecompiler;
compiler-->|JS|babelplugincomponent;
compiler-->|HTML/JS|ssrcompiler;
The SSR compiler handles HTML and JS files, but not CSS files – these are handled by @lwc/style-compiler
for both the SSR and CSR use case.
The decision of whether to compile an HTML/JS file for CSR or SSR is made by a compiler flag (targetSSR
). A file compiled with @lwc/ssr-compiler
is intended to only be used with @lwc/ssr-runtime
.
At runtime, the core logic of the client-side LWC engine is in @lwc/engine-core
. Today, though, this engine is split into @lwc/engine-dom
and @lwc/engine-server
to allow for both SSR and CSR:
graph TD
enginedom[@lwc/engine-dom];
enginecore[@lwc/engine-core];
engineserver[@lwc/engine-server];
enginedom-->enginecore;
engineserver-->enginecore;
This architecture was created at the genesis of LWC SSR to facilitate development of the SSR system, and to ensure that it remains as close as possible to the CSR system.
@lwc/engine-dom
represents the bare minimum API surface needed to communicate with the browser's DOM APIs, whereas @lwc/engine-server
represents a kind of shim for those APIs which constructs a pseudo-DOM, which can then be serialized to an HTML string.
Since this architecture is not particularly performant compared to a dedicated SSR compiler/runtime, in the long term, @lwc/engine-server
is intended to be deprecated and removed in favor of @lwc/ssr-compiler
/@lwc/ssr-runtime
. At this point, there would be no reason to split up @lwc/engine-dom
and @lwc/engine-core
, and they could be merged back together.
On this same note, @lwc/ssr-runtime
can be considered a sibling of @lwc/engine-dom
and @lwc/engine-server
. All three should export roughly the same API surface, (e.g. LightningElement
is exported by each of them).
There are a small number of packages that are shared between the compiler and runtime:
@lwc/errors
@lwc/features
@lwc/shared
@lwc/errors
contains common error messages, @lwc/features
handles global feature flags, and @lwc/shared
is a grab-bag of utilities and types not represented elsewhere.
Some helper packages are not shared between client and server, but still perform small functions:
@lwc/module-resolver
: LWC's custom module resolution logic@lwc/signals
: client-side signals implementation@lwc/types
: TypeScript helper for HTML/CSS imports@lwc/wire-service
: Implementation of the@wire
service
These projects are typically "done" when implemented and rarely need to be touched.
observable-membrane
is also conceptually in this same group, even though it technically lives outside the LWC monorepo.
A separate category of helper packages is our polyfills:
@lwc/aria-reflection
: implementation of ARIA string reflection@lwc/synthetic-shadow
: implementation of Shadow DOM with some LWC-specific hooks
In an ideal world, neither of these packages would exist. For historical reasons and for reasons of backwards compatibility, these packages are still maintained as of this writing.
Some other polyfills are not separate packages but instead part of LWC's core logic. For instance, synthetic custom element lifecycle is small enough to be inlined in @lwc/engine-dom
.
There are several private internal packages for tests and performance benchmarking:
@lwc/integration-karma
: primary client-side LWC test suite, using Karma@lwc/integration-tests
: WebDriverIO tests, used for anything Karma can't do, such as user agent gestures such as changing focus@lwc/integration-types
: TypeScript tests, i.e. tests for the types themselves@lwc/perf-benchmarks
: Performance micro-benchmarks for either Best or Tachometer@lwc/perf-benchmarks-components
: Collection of components used by the above (split into a separate package due to Tachometer restrictions)
In terms of external integrations and dependencies, the LWC open-source monorepo purely depends on open-source projects, including those authored at Salesforce. Today this includes:
observable-membrane
: core reactivity logic@locker/babel-plugin-transform-unforgeables
: special Babel transform for Lightning Web Security
LWC also has several tight integrations with Salesforce-internal projects:
lwc-platform-public
: core integration logic between Salesforce core and the LWC open-source project- Locker/Lightning Web Security: security layer with several integration points with LWC (e.g.
enableLightningWebSecurityTransforms
,addLegacySanitizationHook
,setHooks
, etc.). Many core LWC design decisions (such as shadow DOM) are tightly integrated with design decisions in Locker/LWS. - Lightning Web Runtime: meta-framework which can be thought of as "the Next.js" of LWC.
Some open-source projects live in the same "cinematic universe" but are less tightly coupled to LWC, including:
lwc-test
: LWC Jest testing utilitieseslint-plugin-lwc
: LWC ESLint linting utilities
At its core, LWC is a framework heavily influenced by other popular frameworks of the late 2010's, notably Vue, Svelte, and React. Of the three, it owes the most to Vue's influence.
Like Vue, the original LWC framework design has a few core building blocks:
- Virtual DOM. Like Vue, LWC uses virtual DOM even in cases where a component is authored in HTML. Unlike Vue, LWC does not have a mode where you can author VDOM directly (i.e.
render()
). - VDOM diffing. Like Vue, LWC was originally authored with
snabbdom
but has since forked into its own implementation. - Proxies. Like Vue v3, LWC uses the JavaScript
Proxy
for reactivity.
A big design difference between LWC and Vue (and React and Svelte, for that matter) is its use of shadow DOM and custom elements. You might think that LWC would share a lot with Lit, since they both rely on web components, but in fact Lit v1 was released in 2019, the same year as LWC, so they were largely developed in parallel. (Furthermore, LWC's first commit was in 2016.)
Unlike Lit, LWC's core engine is not based on VDOM-less HTML templating, and unlike Lit, it does not take a "custom-elements-agnostic" approach where child components are treated as generic web components authored in any JavaScript framework. (Instead, LWC defaults to treating all components as LWC components, with lwc:external
as an opt-out.)
Another piece of history that LWC shares with Vue is that it has been slowly moving away from raw VDOM for performance reasons. As such, LWC has adopted several performance optimizations that look more similar to Lit's approach (or Solid's and Svelte's, for that matter). Vue calls this compiler-informed VDOM, Marko calls it compile-time optimization of static sub-trees, and Million calls it Block VDOM, but the concepts are very similar regardless.
In LWC, this is either called the "static content optimization" or "fine-grained reactivity" (although true fine-grained reactivity is, of this writing, still a work in progress). In either case, the core technique is:
- Identify blocks of templated HTML that are static.
- Use a simple string-based
<template>
/innerHTML
/cloneNode
approach rather than expensive VDOM diffing for such blocks.
Over time, this optimization has been expanded to non-static blocks of templated HTML, yielding performance gains at each step, while diverging further and further from the Virtual DOM model. That said, there are still plenty of cases where virtual DOM is used (i.e. de-opts), so the LWC engine largely has two parallel code paths to handle each. (For this reason, CI tests run both with and without the static content optimization enabled.)
If you squint, LWC looks a lot like other frameworks. So in some ways, it's more interesting to talk about where LWC diverges from those frameworks. At a high level, there are a few features where LWC largely "goes its own way".
This is an LWC-specific concept driven by the strong need for backwards compatibility on the Salesforce Lightning platform. This is a whole topic on its own, but is largely covered by the official Salesforce post on the topic.
Suffice it to say: if a breaking change can be contained to the internals of a given component (i.e. one component cannot observe the state of another one), then this breaking change should be done through component-level API versioning. Several such breaking changes have already been made.
The decision to use shadow DOM, custom elements, and the other building blocks of web components was largely made 1) to integrate well with Locker/LWS, and 2) to hew more closely to web standards. As previously mentioned, this does not bring LWC much closer to Lit, but instead gives LWC its own flavor.
LWC is also unique in that it offers a "light DOM" mode which works much more closely to non-web component frameworks such as Vue or Svelte. In this mode, style scoping is still supported, along with <slot>
s, but this works almost identically to Vue and Svelte's implementations of the same concepts, rather than the browser shadow DOM standard.
This is another point of differentiation with Lit, which has resisted implementing such "non-standard" features. In LWC's case the choice was made for pragmatism. (Some use cases prefer light DOM to shadow DOM for its ease of styling and better integration with third-party tools and extensions.) This choice is also similar to Stencil's decision to make shadow DOM optional, or Enhance and Astro making <slot>
s a purely compile-time (i.e. light DOM) concern.
Footnotes
-
Note that this is only tangentially related to the
import { LightningElement } from 'lwc'
idiom, because in this case'lwc'
is more of a compile-time concern than a runtime concern. The LWC compiler is responsible for transforming this'lwc'
import into something else (e.g.@lwc/engine-dom
,@lwc/ssr-runtime
), so it's not a "true" import of thelwc
package. ↩ -
Whenever JS files are mentioned in the context of the compiler, you can assume that TS (TypeScript) files are also included here. ↩
-
External tools like
lwc-webpack-plugin
orvite-plugin-lwc
, which use other bundlers than Rollup, would likely also call directly into@lwc/compiler
. ↩