| name | lit-migration |
| description | This skill should be used when the user asks to "migrate component to lit", "convert to lit", "lit migration", "migrate to litElement", "convert component to Lit", or discusses migrating Tyler Forge components from the legacy component/core/adapter/template architecture to Lit-based components. |
Lit Component Migration
Migrate Tyler Forge components from the legacy component/core/adapter/template architecture to modern Lit-based components.
When to Use
- Migrating components from the legacy 4-file pattern (component/core/adapter/template)
- Converting components extending
BaseComponent to BaseLitElement
- Modernizing components to use Lit's reactive property system
- Simplifying component architecture while maintaining backward compatibility
Migration Workflow
Phase 1: Analysis & Setup
Goal: Understand the component and identify migration complexity.
-
Read all legacy files to understand:
- Component structure and inheritance chain
- Properties, their types, reflection behavior, and attribute mappings
- Event listeners and custom events
- DOM manipulation patterns in adapter
- Business logic in core
- Template structure
- Form association or ARIA requirements
- Complexity level (simple/moderate/complex)
-
Identify migration patterns by reviewing similar migrated components:
- Simple light DOM:
packages/forge/src/lib/accordion/accordion.ts (branch: feat/accordion-lit)
- Complex with shadow DOM:
feat/expansion-panel-lit branch
- With custom setters:
packages/forge/src/lib/icon/icon.ts (branch: feat/icon-lit)
-
Create migration branch:
git checkout -b feat/[component-name]-lit
Phase 2: File Structure Migration
Goal: Set up new file structure and delete legacy files.
-
Ensure clean git state - commit or stash any changes first
-
Delete legacy files:
component-adapter.ts
component-core.ts
component.html (if exists)
-
Keep these files (modified in later phases):
component.ts - Will be completely rewritten
component-constants.ts - Minor updates only
component.scss - Usually unchanged
index.ts - Add deprecations
Phase 3: Component Class Migration
Goal: Transform component class to extend BaseLitElement.
1. Update Imports
Remove:
import { attachShadowTemplate, coerceBoolean, coreProperty, customElement } from '@tylertech/forge-core';
import { BaseComponent } from '../core/base/base-component.js';
import { ComponentCore } from './component-core.js';
import { ComponentAdapter } from './component-adapter.js';
import template from './component.html';
import styles from './component.scss';
Add:
import { CUSTOM_ELEMENT_NAME_PROPERTY, CUSTOM_ELEMENT_DEPENDENCIES_PROPERTY } from '@tylertech/forge-core';
import { customElement, property, state, query } from 'lit/decorators.js';
import { html, nothing, PropertyValues, TemplateResult, unsafeCSS } from 'lit';
import { BaseLitElement } from '../core/base/base-lit-element.js';
import styles from './component.scss';
Add as needed:
import { classMap } from 'lit-html/directives/class-map.js';
import { toggleState, setDefaultAria } from '../core/index.js';
import { removeEmptyAttribute, removeDefaultAttribute } from '../core/utils/lit-utils.js';
2. Update Class Declaration
Before:
@customElement({
name: COMPONENT_CONSTANTS.elementName,
dependencies: [DependencyComponent]
})
export class ComponentNameComponent extends BaseComponent implements IComponentNameComponent {
public static get observedAttributes(): string[] {
return [COMPONENT_CONSTANTS.attributes.PROPERTY_NAME];
}
private _core: ComponentCore;
constructor() {
super();
attachShadowTemplate(this, template, styles);
this._core = new ComponentCore(new ComponentAdapter(this));
}
}
After:
export interface IComponentNameComponent extends BaseLitElement {
propertyName: string;
}
@customElement(COMPONENT_CONSTANTS.elementName)
export class ComponentNameComponent extends BaseLitElement implements IComponentNameComponent {
public static styles = unsafeCSS(styles);
public static [CUSTOM_ELEMENT_NAME_PROPERTY] = COMPONENT_CONSTANTS.elementName;
public static [CUSTOM_ELEMENT_DEPENDENCIES_PROPERTY] = [DependencyComponent];
#internals: ElementInternals;
@property({ type: Boolean, reflect: true })
public propertyName = false;
constructor() {
super();
this.#internals = this.attachInternals();
}
public override willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has('propertyName')) {
toggleState(this.#internals, 'property-name', this.propertyName);
}
}
public render(): TemplateResult {
return html`<div class="forge-component-name" part="root"></div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'forge-component-name': IComponentNameComponent;
}
}
Key changes:
@customElement takes a simple string, not an object
- No
observedAttributes — Lit handles this automatically
- No core/adapter instantiation
- Add static
styles property
- Add deprecated static properties for backward compatibility
- Extends
BaseLitElement instead of BaseComponent
- Interface is
@deprecated and extends BaseLitElement (not IBaseComponent)
- Class JSDoc has NO
@property or @attribute tags — those live on the properties themselves
declare global goes at the bottom of the file, after the class
#internals is always present — needed for custom states
@state JSDoc tags in the class comment document custom CSS states
3. Convert Properties
Legacy pattern (component.ts + component-core.ts):
@coreProperty()
declare public propertyName: string;
private _propertyName = 'default';
public get propertyName(): string {
return this._propertyName;
}
public set propertyName(value: string) {
if (this._propertyName !== value) {
this._propertyName = value;
this._adapter.setPropertyName(value);
this._adapter.toggleHostAttribute(CONSTANTS.attributes.PROPERTY_NAME, !!value);
}
}
Lit pattern - Simple reflected property:
@property({ reflect: true })
public propertyName = 'default';
Note: Don't add type: String - it's the default in Lit. Only specify type for Boolean, Number, Object, or Array.
Lit pattern - Custom attribute name:
@property({ attribute: 'panel-selector', reflect: true })
public panelSelector?: string;
Lit pattern - Boolean property:
@property({ type: Boolean, reflect: true })
public open = false;
Lit pattern - Non-reflected property (objects, functions):
@property({ attribute: false })
public externalUrlBuilder?: IconUrlBuilder;
Lit pattern - With custom converter:
@property({
attribute: 'title-text',
reflect: true,
converter: { toAttribute: removeEmptyAttribute }
})
public titleText = '';
Lit pattern - With custom setter (for validation/transformation):
@property({ reflect: true })
public set name(value: string | undefined) {
if (isDefined(value)) {
this.#name = value?.replace(/\s+/, '');
} else {
this.#name = undefined;
}
}
public get name(): string | undefined {
return this.#name;
}
#name?: string;
Lit pattern - Internal reactive state:
@state() private _isAnimating = false;
CRITICAL CONVENTIONS:
- JSDoc comments MUST precede the property, not in the component's JSDoc
- The class-level JSDoc comment must NOT contain
@property or @attribute tags — only @tag, @summary, @cssproperty, @csspart, @cssclass, @state, @slot, @fires, @dependency, etc.
- Private fields use
#field notation (JavaScript private)
- Exception: Decorated fields use
@state() private _field (TypeScript + underscore)
- Only reflect when the original component reflected
- Set
attribute field when attribute name differs from property (typically kebab-case)
- Omit
type for strings (default)
4. Handle Private Fields
Before (legacy):
private _clickListener: EventListener = this._onClick.bind(this);
After (Lit):
#clickListener: EventListener = this.#onClick.bind(this);
@state() private _isAnimating = false;
Rule: Use #field for private implementation details. Use @state() private _field for reactive internal state that should trigger re-renders.
5. Add Render Method or Override createRenderRoot
For shadow DOM components:
public render(): TemplateResult {
return html`
<div class="forge-component" part="root">
<slot></slot>
</div>
`;
}
For light DOM components (no shadow root):
public override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
Phase 4: Template Migration
Goal: Convert HTML template to Lit render method.
Basic Conversion
Before (component.html):
<template>
<div class="forge-component" part="root">
<div class="header" part="header">
<slot name="header"></slot>
</div>
<div class="content" part="content">
<slot></slot>
</div>
</div>
</template>
After (in render() method):
public render(): TemplateResult {
return html`
<div class="forge-component" part="root">
<div class="header" part="header">
<slot name="header"></slot>
</div>
<div class="content" part="content">
<slot></slot>
</div>
</div>
`;
}
Conditional Rendering
Before (adapter manipulates DOM):
if (this._determinate) {
this._rootElement.insertAdjacentHTML('beforeend', determinateTemplate);
} else {
this._rootElement.insertAdjacentHTML('beforeend', indeterminateTemplate);
}
After (Lit):
public render(): TemplateResult {
return html`
<div class="root" part="root">
${this.determinate ? this.#renderDeterminate() : this.#renderIndeterminate()}
</div>
`;
}
#renderDeterminate(): TemplateResult {
return html`<div class="determinate">...</div>`;
}
#renderIndeterminate(): TemplateResult {
return html`<div class="indeterminate">...</div>`;
}
Or use nothing for optional content:
${this.titleText ? html`<h1>${this.titleText}</h1>` : nothing}
Dynamic Classes
Before (adapter):
this._rootElement.classList.toggle('active', isActive);
this._rootElement.classList.toggle('disabled', isDisabled);
After (Lit with classMap):
import { classMap } from 'lit-html/directives/class-map.js';
public render(): TemplateResult {
return html`
<div class=${classMap({
'forge-component': true,
'active': this.active,
'disabled': this.disabled
})} part="root">
<slot></slot>
</div>
`;
}
Event Handlers
In template:
public render(): TemplateResult {
return html`
<button @click="${this.#handleClick}">Click me</button>
`;
}
With options (capture, passive, etc.):
@click="${{ handleEvent: this.#handleClick, capture: true }}"
DOM Queries
Before (adapter):
private readonly _rootElement: HTMLElement;
constructor(component: IComponent) {
this._rootElement = getShadowElement(component, '.root');
}
After (Lit):
@query('.root') private _rootElement!: HTMLElement;
@queryAll('.item') private _items!: HTMLElement[];
@queryAssignedElements() private _slottedElements!: HTMLElement[];
Access these in updated() lifecycle, not willUpdate().
Phase 5: Logic Migration
Goal: Integrate core/adapter logic into the component.
1. Move Property Change Logic to willUpdate()
Before (core setter):
public set open(value: boolean) {
if (this._open !== value) {
this._open = value;
this._adapter.setOpen(value);
this._adapter.toggleHostAttribute('open', value);
}
}
After (Lit):
public willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has('open')) {
this.#handleOpen();
}
}
#handleOpen(): void {
toggleState(this.#internals, 'open', this.open);
}
Getting the old property value (e.g., to disconnect with old capture value):
public willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has('capture')) {
const oldCapture = changedProperties.get('capture') as boolean;
this.#disconnectTargetElement(oldCapture);
this.#connectTargetElement();
}
}
#disconnectTargetElement(capture = this.capture): void {
this.#targetElement?.removeEventListener('keydown', this.#keyDownListener, { capture });
}
When to use willUpdate() vs updated():
willUpdate() - Property change reactions, state updates, calculations, listener management
updated() - DOM queries, measurements, focus management
IMPORTANT - willUpdate() is async: willUpdate() runs in Lit's async update cycle, so property changes don't take effect synchronously. In tests, use await element.updateComplete before asserting effects of property changes:
element.disabled = false;
await element.updateComplete;
dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
expect(spy).toHaveBeenCalledOnce();
When to use custom setters instead of willUpdate():
Use custom getter/setter (with #field backing) when you need to validate or transform the stored value itself (e.g., sanitizing a string, clamping a number). willUpdate() receives the already-stored value, so it cannot change what gets persisted. See the property-patterns.md reference for examples.
1a. Non-Visual Utility Components (No Template, No Shadow DOM)
For components that are purely behavioral (hidden from view, attach listeners to other elements), use this pattern:
@customElement(COMPONENT_CONSTANTS.elementName)
export class ComponentNameComponent extends BaseLitElement implements IComponentNameComponent {
@property({ reflect: true })
public target = '';
@property({ type: Boolean, reflect: true })
public disabled = false;
#targetElement: HTMLElement | null = null;
#keyDownListener = (evt: KeyboardEvent) => this.#handleKeyDown(evt);
public override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
public override connectedCallback(): void {
super.connectedCallback();
this.style.display = 'none';
this.#tryInitialize();
}
public override disconnectedCallback(): void {
super.disconnectedCallback();
this.#disconnect();
this.#targetElement = null;
}
public override willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has('target') || changedProperties.has('global')) {
this.#tryInitialize();
}
if (changedProperties.has('disabled')) {
if (this.disabled) {
this.#disconnect();
} else {
this.#connect();
}
}
}
#tryInitialize(): void {
if (!this.isConnected) {
return;
}
this.#disconnect();
this.#resolveTargetElement();
if (!this.disabled) {
this.#connect();
}
}
}
Key points:
connectedCallback handles the initial synchronous setup
willUpdate() handles subsequent property changes (async)
- The
isConnected guard makes helper methods safe to call from both contexts
- No
constructor() needed — inline field initialization suffices when there is no #internals
style.display = 'none' replaces the legacy adapter.setHostStyles()
2. Move Event Listeners
Before (core + adapter):
this._adapter.addHostListener('click', this._clickListener);
this._adapter.removeHostListener('click', this._clickListener);
After (Lit):
#clickListener: EventListener = (evt: Event) => this.#handleClick(evt);
public connectedCallback(): void {
super.connectedCallback();
this.addEventListener('click', this.#clickListener);
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener('click', this.#clickListener);
}
#handleClick(evt: Event): void {
}
3. Handle ElementInternals
Use #internals when the component needs any of:
- Custom CSS states — host-level styles driven by a property, or states with external value for consumers
- Form association — see
references/form-association.md
- Default ARIA — setting implicit ARIA roles or properties
#internals: ElementInternals;
constructor() {
super();
this.#internals = this.attachInternals();
}
public override willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has('open')) {
toggleState(this.#internals, 'open', this.open);
}
if (changedProperties.has('disabled')) {
toggleState(this.#internals, 'disabled', this.disabled);
}
}
When to use custom states vs. template classes:
Use toggleState() and :state(...) in SCSS when:
- Styles need to be applied to the host element based on a property (
vertical, open, disabled, etc.)
- The state has value for external consumers who may want to target it in their own CSS
Use classes on internal template elements when:
- The styles are purely internal and do not affect the host element
- There is no value in exposing the state externally
:host(:state(vertical)) {
@include vertical-host;
.forge-component {
@include vertical-base;
}
}
.forge-component--active {
@include active-styles;
}
Document each custom state with a @state tag in the class JSDoc:
For components that also need default ARIA roles or properties:
public override connectedCallback(): void {
super.connectedCallback();
setDefaultAria(this, this.#internals, {
role: 'progressbar',
ariaValueMin: '0',
ariaValueMax: '1'
});
}
4. Extract Complex Logic to Controllers
When to create a controller:
- Complex event handling logic (>50 lines)
- Focus management
- Scroll/resize observation
- Selection state management
- Trigger element synchronization
Example structure:
#triggerController = new ComponentTriggerController(this, {
clickHandler: this.#handleClick.bind(this),
keydownHandler: this.#handleKeyDown.bind(this)
});
import { ReactiveController, ReactiveControllerHost } from 'lit';
export class ComponentTriggerController implements ReactiveController {
#host: ComponentComponent;
#options: ComponentTriggerControllerOptions;
constructor(host: ComponentComponent, options: ComponentTriggerControllerOptions) {
this.#host = host;
this.#options = options;
this.#host.addController(this);
}
public hostConnected(): void {
}
public hostDisconnected(): void {
}
public hostUpdate(): void {
}
}
See references/controller-patterns.md for detailed examples.
Phase 6: Testing & Verification
Goal: Ensure migrated component works identically to legacy version.
1. Run Tests
pnpm test packages/forge/src/lib/[component-name]
pnpm test
Tests should pass without modification. If they fail:
- Verify property names match exactly
- Check event names and detail structure
- Ensure deprecated interfaces are still exported
-
Verify attribute reflection behavior
<<<<<<< feat/divider-lit
- Add
await element.updateComplete between property changes and assertions that depend on listener state or willUpdate() side effects
element.disabled = false;
await element.updateComplete;
dispatchKeyboardEvent({ key: 'a' });
expect(spy).toHaveBeenCalledOnce();
element.key = 'Ctrl+a';
await element.updateComplete;
dispatchKeyboardEvent({ key: 'a', ctrlKey: true });
expect(spy).toHaveBeenCalledOnce();
main
2. Run Build
pnpm build
Fix any TypeScript errors that arise.
3. Run Linter
pnpm lint
Fix any linting errors.
4. Visual Testing
pnpm run dev:forge-docs
Navigate to the component's story in Storybook and verify:
- Component renders correctly
- All properties work (test in controls panel)
- Events fire properly
- Visual appearance unchanged
- Interactions work correctly
- No console errors
5. Accessibility Testing
Verify:
- ARIA attributes set correctly
- Keyboard navigation works
- Screen reader announcements correct
- Focus management works
- Custom states applied correctly
6. Property & Attribute Verification
Test in browser console:
const el = document.querySelector('forge-component');
el.propertyName = 'new-value';
console.log(el.getAttribute('property-name'));
el.setAttribute('property-name', 'attr-value');
console.log(el.propertyName);
el.setAttribute('open', '');
console.log(el.open);
el.removeAttribute('open');
console.log(el.open);
Phase 7: Cleanup & Documentation
Goal: Finalize migration and document changes.
1. Update index.ts
Add deprecation to the define function:
export function defineComponentNameComponent(): void {
defineCustomElement(ComponentNameComponent);
}
The interface @deprecated annotation lives directly on the interface in the component file (component.ts), not in index.ts. The interface must also extend BaseLitElement (not IBaseComponent) so consumers have access to updateComplete and other Lit lifecycle members via the type:
export interface IComponentNameComponent extends BaseLitElement {
propertyName: string;
}
2. Update Constants (Optional)
If constants expose internal implementation, consider deprecating:
export const COMPONENT_NAME_CONSTANTS = { ... };
3. Clean Up TODOs
Remove or address any TODO comments added during migration:
// TODO: remove attribute reflection - Decide if reflection is still needed
// TODO: clarify types - Finalize type definitions
- Any other temporary notes
4. Update Tests (If Necessary)
If tests accessed internal implementation details:
- Update to test through public API
- Remove direct access to private fields
- Update to test behavior, not implementation
5. Create Changeset
pnpm changeset
Select the package and change type:
- Major - If there are breaking changes to public API
- Minor - If adding new features (rare in migration)
- Patch - If only internal changes, no API changes
Example changeset content:
---
'@tylertech/forge': major
---
**component-name**: Migrated to Lit architecture.
BREAKING CHANGE: Internal architecture changed from component/core/adapter pattern to Lit-based component. The public API remains unchanged for consumers, but internal implementation details are no longer accessible.
- Removed `ComponentNameCore` and `ComponentNameAdapter` classes
- Component now extends `BaseLitElement` instead of `BaseComponent`
- All functionality preserved with improved performance and maintainability
6. Commit Changes
Use conventional commit format:
git add .
git commit -m "feat(component-name)!: migrate to Lit
BREAKING CHANGE: Internal architecture changed to Lit.
Component API remains the same for consumers."
7. Create Pull Request
git push -u origin feat/component-name-lit
gh pr create --title "feat(component-name)!: migrate to Lit" --body "$(cat <<'EOF'
## Summary
Migrates component-name from legacy component/core/adapter/template architecture to Lit-based component.
## Changes
- Consolidated 4 files into single component file
- Replaced core/adapter pattern with Lit reactive properties
- Maintained all existing functionality and public API
- Added backward compatibility for legacy decorators
## Testing
- [ ] All existing tests pass
- [ ] Build succeeds
- [ ] Linter passes
- [ ] Visual testing in Storybook verified
- [ ] Accessibility verified
## Breaking Changes
Internal architecture changed - consumers should see no differences, but internal implementation details are no longer accessible.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
Quick Reference
Import Mappings
| Legacy | Lit | Notes |
|---|
@tylertech/forge-core decorators | lit/decorators.js | Use Lit's decorators |
BaseComponent | BaseLitElement | New base class |
attachShadowTemplate() | render() method or createRenderRoot() | Lit handles templates |
@coreProperty() | @property() | Lit's reactive properties |
@customElement({...}) | @customElement(string) | Simpler decorator |
coerceBoolean() | type: Boolean in decorator | Lit handles coercion |
getShadowElement() | @query() | Lit query decorators |
Property Decorator Patterns
| Pattern | Decorator | Notes |
|---|
| Simple string, reflected | @property({ reflect: true }) | Omit type for strings |
| Custom attribute name | @property({ attribute: 'kebab-case', reflect: true }) | Set explicit attribute name |
| Boolean | @property({ type: Boolean, reflect: true }) | Specify type for booleans |
| Number | @property({ type: Number, reflect: true }) | Specify type for numbers |
| Object/Array | @property({ type: Object }) | Never reflected |
| Non-reflected | @property({ attribute: false }) | No attribute sync |
| With converter | @property({ converter: { toAttribute: fn } }) | Custom conversion |
| Internal state | @state() private _value | Not reflected, triggers renders |
| With validation | Custom setter with #field backing | Sanitize in setter |
Lifecycle Method Mappings
| Legacy | Lit | Use Case |
|---|
constructor() | constructor() | Essential initialization only |
connectedCallback() | connectedCallback() | Setup needing DOM |
disconnectedCallback() | disconnectedCallback() | Cleanup |
attributeChangedCallback() | willUpdate() | Property change handling |
| Core initialize() | connectedCallback() + willUpdate() | Split setup logic |
| Core destroy() | disconnectedCallback() | Cleanup |
| Adapter DOM updates | render() + updated() | Declarative + imperative |
| - | firstUpdated() | First render complete |
| - | createRenderRoot() | Light DOM override |
Lit Directives & Utilities
| Directive/Utility | Import | Use Case |
|---|
classMap | lit-html/directives/class-map.js | Dynamic classes |
styleMap | lit-html/directives/style-map.js | Dynamic styles |
nothing | lit | Render nothing |
@query() | lit/decorators.js | Query shadow DOM |
@queryAll() | lit/decorators.js | Query all matches |
@queryAssignedElements() | lit/decorators.js | Query slotted elements |
removeEmptyAttribute | ../core/utils/lit-utils.js | Converter for empty strings |
removeDefaultAttribute | ../core/utils/lit-utils.js | Converter for default values |
toggleState | ../core/utils/utils.js | ElementInternals custom states |
setDefaultAria | ../core/utils/a11y-utils.js | ARIA properties |
Common Issues & Solutions
Issue: Property changes not triggering re-render
Symptom: Component doesn't update when property is set.
Solution: Ensure property has @property() decorator, or use @state() for internal state. Lit only re-renders when decorated properties change.
public myProperty = 'value';
@property({ reflect: true })
public myProperty = 'value';
@state() private _myState = 'value';
Issue: Attribute not syncing to property
Symptom: Setting attribute doesn't update property, or vice versa.
Solution:
- Set
reflect: true in property decorator
- Verify
attribute field matches expected attribute name
- For booleans, ensure
type: Boolean is set
@property()
public open = false;
@property({ type: Boolean, reflect: true })
public open = false;
Issue: Cannot access shadow DOM elements in willUpdate()
Symptom: @query() decorated properties are undefined in willUpdate().
Solution: Use updated() lifecycle instead of willUpdate() for DOM queries. Lit renders after willUpdate() completes.
public willUpdate(changedProperties: PropertyValues): void {
this._rootElement.classList.add('active');
}
public updated(changedProperties: PropertyValues): void {
this._rootElement.classList.add('active');
}
Issue: Tests failing after migration
Symptom: Previously passing tests now fail.
Common causes:
- Property names changed - Verify exact match with legacy
- Event names changed - Check custom event names
- Interfaces not exported - Ensure deprecated interfaces still exported
- Timing issues - Lit updates are async, use
await el.updateComplete
Solution:
await element.updateComplete;
await element.updateComplete;
await new Promise(resolve => setTimeout(resolve, 0));
await element.updateComplete;
Issue: Styles not applying
Symptom: Component has no styles or wrong styles.
Solution:
- Check
static styles = unsafeCSS(styles) is set
- Verify shadow DOM vs light DOM - light DOM components don't use
styles property
- Ensure styles import is correct
public static styles = unsafeCSS(styles);
public override createRenderRoot() {
return this;
}
Issue: Infinite re-render loop
Symptom: Component keeps re-rendering, browser freezes.
Cause: Property being set in willUpdate() or updated() that causes another update.
Solution: Use guards to prevent redundant updates:
public willUpdate(changedProperties: PropertyValues): void {
this.derivedProperty = this.property1 + this.property2;
}
public willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has('property1') || changedProperties.has('property2')) {
const newValue = this.property1 + this.property2;
if (this.derivedProperty !== newValue) {
this.derivedProperty = newValue;
}
}
}
Issue: Property initialized as undefined instead of default value
Symptom: Property that should have default value is undefined.
Solution: Ensure default value is set in property declaration:
@property({ reflect: true })
public myProperty?: string;
@property({ reflect: true })
public myProperty = 'default-value';
Additional Resources
See the references/ directory for detailed documentation:
Example Migrations
Study these completed migrations for patterns:
Simple Shadow DOM Component
- Branch:
feat/divider-lit
- File:
packages/forge/src/lib/divider/divider.ts
- Patterns: Boolean reflected property, custom state, shadow DOM, ElementInternals,
:state(...) SCSS
Simple Light DOM Component
- Branch:
feat/accordion-lit
- File:
packages/forge/src/lib/accordion/accordion.ts
- Patterns: Basic properties, event handling, light DOM, no render method
Complex Shadow DOM Component
- Branch:
feat/expansion-panel-lit
- Patterns: ElementInternals, custom states, controllers, animations, shadow DOM
Component with Custom Setters
- Branch:
feat/icon-lit
- File:
packages/forge/src/lib/icon/icon.ts
- Patterns: Property validation, custom setters, lazy loading, external content
<<<<<<< feat/divider-lit
Non-Visual Utility Component (No Template, No Shadow DOM)
- Branch:
feat/keyboard-shortcut-lit
- File:
packages/forge/src/lib/keyboard-shortcut/keyboard-shortcut.ts
- Patterns:
createRenderRoot() returning this, no render(), display: none in connectedCallback, willUpdate() for listener management, isConnected guard, old value via changedProperties.get(), #disconnect(capture = this.capture) optional parameter for old-value disconnect
main
Conventions Summary
CRITICAL - Follow these conventions:
- JSDoc placement: Property JSDoc MUST precede the property. Class JSDoc must NOT contain
@property or @attribute tags — only @tag, @summary, @cssproperty, @csspart, @cssclass, @state, @slot, @fires, @dependency, etc.
- Interface: The legacy
IComponentNameComponent interface must be marked @deprecated and must extend BaseLitElement (not IBaseComponent). Keep it exported for backward compatibility.
declare global: Always placed at the bottom of the file, after the class declaration.
- Private fields: Use
#field notation (JavaScript private)
- Exception: Decorated state uses
@state() private _field (TypeScript + underscore)
- Reflection: Only reflect when original component reflected to attributes
- Attribute names: Set
attribute field when names differ (typically kebab-case)
- Property validation: Use custom setters for validation/transformation
- Change handling: Prefer
willUpdate() over updated() for property reactions
- String type: Omit
type: String - it's the default in Lit
- Custom states: Use
toggleState() and :state(...) in SCSS when styles target the host element or the state has external styling value. Use internal classes for purely internal styling.
- Goal: Simplify code, reduce boilerplate, leverage Lit features
- Controllers: Extract verbose code into controllers/directives for clarity
- Behavior: No changes to consumer-facing behavior or appearance