Skip to content

Latest commit

 

History

History
864 lines (673 loc) · 29.2 KB

File metadata and controls

864 lines (673 loc) · 29.2 KB

Composable Feature-Centric Controllers with Inheritance

A proposal for extending Lit with declarative, inheritable, composable features

Executive Summary

This proposal introduces Composable Feature-Centric Controllers — a pattern that extends Lit's ReactiveController concept to enable declarative composition of behaviors through inheritance, while maintaining full integration with Lit's reactive property system.

The system allows component authors to:

  • Provide reusable features (controllers) at any level in a class hierarchy
  • Configure or disable inherited features in subclasses
  • Define reactive properties within features that seamlessly merge into the host component
  • Compose multiple concerns (ripple/pulse feedback, theming, dismissal workflows) without deep mixin chains

Integration Goal: The architecture demonstrated in this POC (LitCore, LitFeature, decorators, and FeatureManager) would be integrated directly into LitElement and ReactiveElement, making features a native part of Lit's component model rather than a separate library.

Core Concept: Composable Feature-Centric Controllers

What is a Feature?

A Feature is a specialized ReactiveController that:

  1. Implements ReactiveController - participates in host lifecycle
  2. Declares reactive properties - properties that merge into the host's property system
  3. Encapsulates single-responsibility behavior - feedback, theming, dismissal, etc.
  4. Is inheritable and configurable - subclasses can reconfigure or disable features
  5. Supports standard JavaScript class inheritance - features can extend other feature classes or base classes, inheriting properties and methods for more granular control

Key Principles

  1. Declarative Composition: Features are declared via class decorators or static getters, not imperatively instantiated
  2. Inheritance-Aware: Features flow down class hierarchies and can be reconfigured at each level
  3. Property Integration: Feature properties become host properties automatically
  4. Single Responsibility: Each feature manages one concern (ripple/pulse feedback, theme, dismissal)
  5. Inter-Feature Communication: Features can discover and communicate with other features on the same host
  6. Class-Based Extensibility: Features leverage standard JavaScript class inheritance, allowing custom feature subclasses with specialized properties and methods for more granular control

Feature Class Inheritance

Features support standard JavaScript class inheritance, enabling developers to create feature subclasses that extend base features with specialized properties and methods. This provides another vector for fine-grained control while maintaining all the benefits of the feature system.

Example: Extending BaseDismissFeature

// Base feature in library
export class BaseDismissFeature extends LitFeature {
  @property({ type: Boolean, reflect: true })
  dismissible = true;

  dismiss(): void { /* ... */ }
}

// Custom subclass in application code
export class AnalyticsDismissFeature extends BaseDismissFeature {
  @property({ type: String })
  dismissSource: 'manual' | 'auto' = 'manual';

  override dismiss(): void {
    this.dismissSource = 'manual';
    super.dismiss();
  }

  trackDismiss(): void { /* ... */ }
}

// Use custom feature in component
@provide('Dismiss', { class: AnalyticsDismissFeature })
class CustomNotice extends LitCore {
  declare Dismiss: AnalyticsDismissFeature;
  declare dismissible: boolean;
  declare dismissSource: 'manual' | 'auto';
}

Benefits of Feature Inheritance:

  • Composition over duplication - Subclass features reuse base feature logic rather than duplicating it
  • Specialized variants - Create custom feature variants for specific use cases without modifying the base feature
  • Gradual enhancement - Layer functionality by extending existing features incrementally
  • Maintains integration - Feature properties and lifecycle management work seamlessly in subclasses

Features in the Codebase

This POC implements a small set of demo features used by the showcase:

1. RippleFeature

Purpose: Adds click feedback with a material-style ripple.

Properties:

  • rippling: boolean - Reflects when the ripple animation is active

Demonstrates:

  • Feature-provided styles
  • Host event wiring
  • Minimal reactive property surface

2. PulseFeature

Purpose: Adds a pulsing attention state.

Properties:

  • pulsing: boolean - Toggles CSS animation

Methods:

  • togglePulse() - Convenience toggle

Demonstrates:

  • Config-driven defaults
  • CSS animation control via reactive properties

3. ThemeFeature

Purpose: Theme management with system preference support.

Properties:

  • theme: 'light' | 'dark' | 'auto'
  • colors: ThemeColors - Computed palette
  • systemTheme: 'light' | 'dark'

Methods:

  • toggleTheme()
  • getResolvedTheme()

Demonstrates:

  • Decorator + static property declarations
  • Lifecycle hooks (connectedCallback, updated)
  • CSS variable propagation

4. Dismiss Feature Inheritance

Purpose: Manual, timed, and swipe-based dismissal using multi-level feature inheritance.

Chain:

  • BaseDismissFeatureAutoDismissFeatureSwipeDismissFeature

Demonstrates:

  • Feature inheritance in practice
  • Host event listeners for gesture handling
  • Custom events for dismissal source tracking

How It Works

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                        LitElement                            │
│                             │                                │
│                             ↓                                │
│                        LitCore                               │
│         ┌──────────────────┴──────────────────┐            │
│         │                                       │            │
│    FeatureManager                    Feature Resolver       │
│         │                                       │            │
│         ↓                                       ↓            │
│  Feature Instances  ←────────────  Feature Snapshot         │
│         │                            (merged config)         │
│         ↓                                                    │
│   LitFeature (base)                                         │
│         │                                                    │
│         ├─── RippleFeature                                  │
│         ├─── PulseFeature                                   │
│         ├─── ThemeFeature                                   │
│         └─── BaseDismissFeature → AutoDismissFeature → SwipeDismissFeature │
└─────────────────────────────────────────────────────────────┘

Core Components

1. LitCore (extends LitElement)

  • Entry point for feature-enabled components
  • Hosts a FeatureManager instance
  • Overrides finalize() to merge feature properties into component properties
  • Extends lifecycle methods to propagate to features

Key Methods:

static finalize(): void {
  // Merge feature properties into host properties
  const snapshot = resolveFeatureSnapshot(this);
  this.properties = {
    ...superProps,
    ...snapshot.properties,  // ← Feature properties merged here
    ...ownProps
  };
  super.finalize();
}

connectedCallback(): void {
  this.featureManager.processLifecycle('beforeConnectedCallback');
  super.connectedCallback();
  this.featureManager.processLifecycle('connectedCallback');
  this.featureManager.processLifecycle('afterConnectedCallback');
}

2. LitFeature (implements ReactiveController)

  • Base class for all features
  • Creates property proxies that sync feature properties with host properties
  • Implements ReactiveController interface
  • Manages internal property storage and observation

Key Responsibilities:

  • Property proxy creation: Getting/setting feature properties reads/writes host properties
  • Property observation: Tracks changes and triggers host updates
  • Lifecycle participation: Receives lifecycle callbacks via ReactiveController

Property Synchronization:

private _createPropertyObserver(propertyName: string): void {
  Object.defineProperty(this, propertyName, {
    get() {
      return feature.getInternalValue(propertyName);
    },
    set(newValue: unknown) {
      // Write to host's reactive property
      hostRecord[propertyName] = newValue;
      // Trigger Lit's update cycle
      this.host.requestUpdate(propertyName, oldValue);
    }
  });
}

3. FeatureManager

  • Service that manages feature lifecycle
  • Instantiates features based on resolved snapshot
  • Propagates host lifecycle events to all features
  • Handles feature registration and cleanup

Initialization Flow:

private _initializeFeatures(): void {
  const { provides, configs } = resolveFeatureSnapshot(this.hostConstructor);
  
  Object.entries(provides).forEach(([name, definition]) => {
    if (configs[name] === 'disable') return;
    
    // Merge configs from inheritance chain
    const finalConfig = merge({}, defaultConfig, userConfig);
    const instance = new FeatureClass(this.host, finalConfig);
    
    // Store on host: this.FeatureName = instance
    this.host[name] = instance;
  });
}

4. Feature Resolver

  • Resolves feature definitions across inheritance chains
  • Merges configs from parent classes with child overrides
  • Caches resolved snapshots for performance
  • Handles property disabling and overrides

Resolution Algorithm:

1. Walk inheritance chain (bottom-up, excluding LitElement)
2. Collect all @provide decorators and static provide getters
3. Collect all @configure decorators and static configure getters
4. Merge configs using lodash.merge (deep merge)
5. Apply property overrides/disables
6. Create final snapshot with:
   - provides: Map of feature name → definition
   - configs: Map of feature name → merged config
   - properties: Merged property declarations
7. Cache snapshot on constructor

Decorators

@provide(name, definition)

Declares that a class provides a feature:

@provide('Theme', {
  class: ThemeFeature,
  config: { defaultTheme: 'light', respectSystemTheme: true }
})
export class ThemedCard extends LitCore {}

@configure(name, options | 'disable')

Configures or disables an inherited feature:

@configure('Theme', {
  config: { defaultTheme: 'dark' },
  properties: { systemTheme: 'disable' }
})
export class DarkCard extends ThemedCard {}

// Or disable entirely:
@configure('Pulse', 'disable')
export class NoPulseBadge extends LitCore {}

@property(options) (on feature classes)

Declares reactive properties within features:

export class ThemeFeature extends LitFeature {
  @property({ type: String, reflect: true })
  theme: 'light' | 'dark' | 'auto' = 'light';
}

Example Usage

Inheritance Chain:

// Tier 1: Provide click feedback
@provide('Ripple', { class: RippleFeature })
class SimpleButton extends LitCore {
  declare Ripple: RippleFeature;
  declare rippling: boolean;
}

// Tier 2: Provide theme with configuration overrides
@provide('Theme', { class: ThemeFeature, config: { defaultTheme: 'auto' } })
class ThemedPanel extends LitCore {
  declare Theme: ThemeFeature;
  declare theme: 'light' | 'dark' | 'auto';
}

// Tier 3: Swap in different dismiss feature classes
@provide('Dismiss', { class: BaseDismissFeature })
class BasicNotification extends LitCore {
  declare Dismiss: BaseDismissFeature;
  declare dismissed: boolean;
}

@provide('Dismiss', { class: AutoDismissFeature, config: { autoDismiss: true } })
class AutoNotification extends LitCore {
  declare Dismiss: AutoDismissFeature;
  declare dismissed: boolean;
}

@provide('Dismiss', { class: SwipeDismissFeature, config: { swipeToDismiss: true } })
class SwipeNotification extends LitCore {
  declare Dismiss: SwipeDismissFeature;
  declare dismissed: boolean;
}

At runtime:

const notice = new SwipeNotification();
// All feature instances available:
notice.Dismiss.dismiss();

// All feature properties available on host:
notice.dismissed = false;

Integration Plan for Lit Core

The goal is to integrate this pattern directly into Lit's ReactiveElement and LitElement, making features a native capability rather than a separate framework.

Required Changes to Lit Core

1. ReactiveElement Changes

Add feature resolution to finalize():

protected static finalize() {
  // ... existing property resolution ...
  
  // NEW: Resolve and merge feature properties
  if (this.hasOwnProperty('provide') || this.hasOwnProperty('configure')) {
    const featureSnapshot = this._resolveFeatures();
    this._featureSnapshot = featureSnapshot;
    
    // Merge feature properties into element properties
    Object.assign(this.elementProperties, featureSnapshot.properties);
  }
  
  // ... rest of finalize ...
}

Add feature instantiation to constructor:

constructor() {
  super();
  // ... existing initialization ...
  
  // NEW: Initialize feature manager if features are present
  if ((this.constructor as any)._featureSnapshot) {
    this._featureManager = new FeatureManager(this);
  }
}

Extend lifecycle methods:

connectedCallback() {
  this._featureManager?.processLifecycle('beforeConnectedCallback');
  // ... existing connectedCallback ...
  this._featureManager?.processLifecycle('afterConnectedCallback');
}

// Similar for disconnectedCallback, updated, firstUpdated, etc.

2. Feature Base Class

Add Feature as a core export (similar to ReactiveController):

// Part of lit/reactive-element
export class Feature<TConfig = any> implements ReactiveController {
  host: ReactiveElement;
  config: TConfig;
  
  static properties: PropertyDeclarations = {};
  
  constructor(host: ReactiveElement, config: TConfig) {
    this.host = host;
    this.config = config;
    host.addController(this);
    this._initializeProperties();
  }
  
  // Property synchronization logic
  // Lifecycle methods
}

3. Decorator Enhancements

The existing @property() decorator would detect context:

export function property(options?: PropertyDeclaration) {
  return (protoOrTarget: any, nameOrContext: string | ClassFieldDecoratorContext) => {
    // Determine if we're decorating a Feature or ReactiveElement
    const isFeature = protoOrTarget instanceof Feature || 
                      protoOrTarget.prototype instanceof Feature;
    
    if (isFeature) {
      // Store in feature metadata
      return createFeatureProperty(options);
    } else {
      // Existing element property logic
      return createElementProperty(options);
    }
  };
}

4. New Decorators

Add @provide and @configure as core decorators:

// Part of lit/decorators
export { provide, configure } from './feature-decorators.js';

Backward Compatibility

All changes are additive only:

  • Existing components without features work exactly as before
  • Features are opt-in via @provide or static provide
  • No breaking changes to existing APIs
  • Feature system is isolated and only activates when used

Performance Considerations

  1. Lazy Resolution: Feature snapshots are resolved once and cached
  2. Conditional Overhead: Feature manager only instantiates if features are present
  3. Property Optimization: Property merging happens at finalize time (once per class)
  4. Lifecycle Delegation: Minimal overhead in lifecycle methods (Map iteration)

Property Decorator Integration Strategy

The @property decorator integration is key to making features feel native to Lit.

Current State (POC)

Features use a separate @property decorator from './decorators/feature-property.js':

export class ThemeFeature extends LitFeature {
  @property({ type: String, reflect: true })
  theme: 'light' | 'dark' | 'auto' = 'light';
}

Proposed Integration

Single unified @property decorator that intelligently detects context:

import { property } from 'lit/decorators.js';

// Works on LitElement
export class MyElement extends LitElement {
  @property({ type: String })
  name: string = 'default';
}

// Works on Feature (automatically!)
export class MyFeature extends Feature {
  @property({ type: String, reflect: true })
  status: string = 'active';
}

Implementation Approach

The decorator inspects the class hierarchy to determine behavior:

export function property(options?: PropertyDeclaration) {
  return (protoOrTarget: any, nameOrContext: PropertyKey | DecoratorContext) => {
    const target = getTargetFromContext(protoOrTarget, nameOrContext);
    const ctor = target.constructor;
    
    // Check if this is a Feature class
    const isFeatureClass = isFeature(ctor);
    
    if (isFeatureClass) {
      // Feature property behavior:
      // 1. Store in feature's static properties
      // 2. Register in feature metadata for host merging
      // 3. Will be merged into host during finalize()
      return handleFeatureProperty(ctor, nameOrContext, options);
    } else {
      // Standard ReactiveElement property behavior
      return handleElementProperty(target, nameOrContext, options);
    }
  };
}

function isFeature(ctor: any): boolean {
  // Walk prototype chain looking for Feature base class
  let proto = ctor;
  while (proto && proto !== Object) {
    if (proto.name === 'Feature') return true;
    proto = Object.getPrototypeOf(proto);
  }
  return false;
}

Benefits

  1. Single Import: Users import from 'lit/decorators.js' for both elements and features
  2. Consistent API: Same decorator syntax regardless of context
  3. Type Safety: TypeScript types work identically
  4. Zero Confusion: No need to remember which decorator to use where

Migration Path

For codebases using the POC:

// Before (POC):
import { property } from './root/decorators/feature-property.js';

// After (integrated):
import { property } from 'lit/decorators.js';

Outstanding Questions & Discussion Points

1. Naming Conventions

Question: Should we use provide/configure or explore alternatives?

Alternatives considered:

  • @feature() / @featureConfig() - More verbose but clearer
  • @use() / @setup() - Shorter but less semantic
  • @mixin() / @mixinConfig() - Familiar but potentially confusing with actual mixins

Recommendation: Keep @provide and @configure - they clearly express intent and avoid confusion with other patterns.

2. Static Getters vs Decorators

Question: Should we support both patterns or standardize on decorators?

Current POC: Supports both

// Decorators (modern)
@provide('Feature', definition)

// Static getters (traditional)
static get provide() { return { Feature: definition }; }

Considerations:

  • Decorators are more ergonomic for single features
  • Static getters are better for defining many features at once
  • Some users prefer avoiding decorators (stage 3 concern)
  • Both patterns can coexist with minimal complexity

Recommendation: Support both patterns in core, but document decorators as the primary/preferred approach.

3. Feature Naming on Host

Question: Should features be stored as this.FeatureName or in a namespace like this.features.FeatureName?

Current POC: Direct properties (this.Theme, this.Dismiss)

Alternatives:

// Option A: Direct (current)
this.Status.getStatusIcon();

// Option B: Namespaced
this.features.Theme.getResolvedTheme();

// Option C: Private with accessors
this._features.get('Theme').getResolvedTheme();

Trade-offs:

  • Direct: Cleaner API, potential naming conflicts
  • Namespaced: Clearer separation, no conflicts, more verbose
  • Private: Encapsulation, requires explicit accessors

Recommendation: Start with direct properties (current approach) but reserve property names starting with capital letters for features only. This convention makes feature instances easily distinguishable from component properties.

4. TypeScript Declare Support

Question: How can we improve the DX for feature property types?

Current approach:

@provide('Theme', { class: ThemeFeature })
class MyElement extends LitCore {
  declare Theme: ThemeFeature;  // Manual declaration
  declare theme: 'light' | 'dark' | 'auto';
}

Desired: Automatic type inference or code generation

Challenges:

  • TypeScript decorators don't modify class types
  • No way to automatically add types from feature classes

Potential Solutions:

  1. Vue-style defineComponent helper:

    export const MyElement = defineComponent(LitCore)
      .withFeature('Theme', ThemeFeature)
      .build();
    // Type inference from builder pattern
  2. TypeScript transformer plugin (build step)

  3. JSDoc with @typedef for JavaScript users

  4. Accept manual declare statements as acceptable DX (current approach)

Recommendation: Start with manual declare statements and explore builder pattern or TS transformers in future iterations.

5. Feature Lifecycle Granularity

Question: Should we expose more granular lifecycle hooks?

Current hooks:

- beforeConnectedCallback / connectedCallback / afterConnectedCallback
- beforeDisconnectedCallback / disconnectedCallback / afterDisconnectedCallback
- beforeFirstUpdated / firstUpdated / afterFirstUpdated
- beforeUpdated / updated / afterUpdated
- beforeAttributeChangedCallback / afterAttributeChangedCallback

Considerations:

  • More hooks = more power but more complexity
  • Most features only need a few lifecycle points
  • Performance impact of calling many no-op methods

Recommendation: Keep current granularity for now. The before/after hooks provide flexibility for feature coordination without being overwhelming. Consider adding more hooks if clear use cases emerge.

6. Feature-to-Feature Dependencies

Question: Should we support explicit feature dependencies?

Current approach: Features discover each other at runtime

const otherFeature = this.host.OtherFeature;
if (otherFeature) {
  otherFeature.doSomething();
}

Alternative: Explicit dependency declaration

@provide('Dismiss', {
  class: SwipeDismissFeature,
  requires: ['Theme']  // ← Enforce at resolution time
})

Trade-offs:

  • Explicit: Fails fast, clearer contracts, more rigid
  • Optional: More flexible, requires null checks, potential runtime errors

Recommendation: Start with optional discovery (current approach). Add requires and optional dependency declarations in a future iteration if ecosystem adoption shows clear patterns.

7. Disabling Features at Runtime

Question: Should features be disable-able after instantiation?

Current: Features can only be disabled at configuration time

@configure('Pulse', 'disable')

Potential: Runtime disabling

this.featureManager.disableFeature('Pulse');
this.featureManager.enableFeature('Pulse');

Use case: Toggle features based on runtime conditions (permissions, feature flags, etc.)

Recommendation: Keep static configuration for MVP. Runtime toggling adds significant complexity (property cleanup, lifecycle management) without clear must-have use cases yet.

8. Feature Composition (Features of Features?)

Question: Should features be able to compose other features?

Scenario:

// Can a feature itself use the @provide/@configure system?
class ComplexFeature extends Feature {
  @provide('SubFeature', { class: SubFeature })
  static provide = { ... };
}

Considerations:

  • Powerful for complex behaviors
  • Adds conceptual and implementation complexity
  • Current pattern encourages flat composition

Recommendation: Defer this. Current pattern of flat feature composition is simpler and covers most use cases. Nested features can be explored if adoption reveals a strong need.

9. Feature Property Collision Handling

Question: What happens when two features define the same property name?

Current behavior: Last feature wins (merge order dependent)

Alternatives:

  1. Throw error at finalize time
  2. Namespace properties automatically (e.g., Theme_primary, Animation_duration)
  3. Require explicit resolution via @configure

Recommendation: Throw error at finalize time (fail fast). Property collisions indicate poor feature design or naming. Developers should resolve by:

  • Renaming properties in custom feature subclass
  • Using properties: { conflictName: 'disable' } in @configure
  • Choosing better feature names

10. Integration with Reactive Controllers

Question: How do features relate to existing ReactiveController usage?

Current: Features ARE reactive controllers (implement the interface)

Key point: Features and controllers coexist:

class MyElement extends LitElement {
  @provide('Theme', { class: ThemeFeature })
  static provide = { ... };
  
  // Also use traditional controllers
  private _mouseController = new MouseController(this);
  
  // Theme feature available as this.Theme
  // Mouse controller used traditionally
}

Recommendation: Position features as "reactive controllers with superpowers" (inheritance + property merging). They complement rather than replace existing controller patterns. Document migration path from controllers to features.

Next Steps

Phase 1: POC Refinement (Current)

  • Implement core architecture (LitCore, LitFeature, FeatureManager)
  • Create example features (Ripple, Pulse, Theme, Dismiss chain)
  • Demonstrate inheritance chain
  • Support both decorators and static getters
  • Create working demo components
  • Write comprehensive test suite
  • Document all APIs
  • Create this proposal

Phase 2: POC Validation

  • Share POC with Lit team for initial feedback
  • Present at Lit community meeting
  • Gather feedback on API design and use cases
  • Iterate on decorator/property integration strategy
  • Benchmark performance vs. mixin patterns
  • Address outstanding questions based on feedback

Phase 3: Core Integration Prototype

  • Create branch of Lit core with feature system integrated
  • Implement unified @property decorator
  • Add Feature base class to reactive-element
  • Port feature resolution into ReactiveElement.finalize()
  • Create comprehensive test suite for core integration
  • Update Lit's TypeScript definitions
  • Write migration guide from POC to core

Phase 4: Documentation & Examples

  • Write tutorials for common feature patterns
  • Document feature lifecycle in detail
  • Create best practices guide
  • Build reference features for common UI patterns:
    • Focus management
    • Keyboard navigation
    • ARIA roles/attributes
    • Animation coordination
    • Form validation
    • Responsive behavior
  • Port existing Lit patterns to features (show equivalents)

Phase 5: Community Preview

  • Release as experimental feature behind flag
  • Create migration tools for existing codebases
  • Gather real-world usage feedback
  • Iterate on DX and performance
  • Address TypeScript typing limitations
  • Refine based on ecosystem patterns

Phase 6: Stable Release

  • Finalize API based on preview feedback
  • Complete documentation
  • Release as stable feature in Lit
  • Update lit.dev with feature system docs
  • Create Lit labs packages for common features
  • Present at web component conferences

Success Metrics

Developer Experience:

  • Reduced mixin depth in component libraries
  • Improved code reusability across component hierarchies
  • Clearer separation of concerns in components
  • Faster onboarding for new team members

Technical:

  • No performance regression vs. mixin patterns
  • Minimal bundle size increase
  • Full TypeScript support
  • Compatible with existing Lit decorators and patterns

Adoption:

  • Major design systems adopt feature patterns
  • Community creates reusable feature packages
  • Positive feedback from Lit ecosystem

Conclusion

Composable Feature-Centric Controllers extend Lit's already excellent composition model with inheritance-aware, declarative feature management. This pattern:

  • Simplifies component inheritance by avoiding deep mixin chains
  • Enables fine-grained control over inherited behaviors
  • Maintains Lit's reactive property system and lifecycle model
  • Provides clear mental model: features are "controllers with superpowers"
  • Integrates naturally into Lit's existing architecture

The POC demonstrates that this pattern is technically feasible, ergonomic, and powerful. With community feedback and refinement, it has the potential to become a core part of how developers build composable web components with Lit.


POC Repository: LitFeature
Primary Contact: [Add contact info]
Status: Proposal / Proof of Concept
Date: February 19, 2026