-
Notifications
You must be signed in to change notification settings - Fork 815
Description
Prerequisites
- I have read the Contributing Guidelines.
- I agree to follow the Code of Conduct.
- I have searched for existing issues that already include this feature request, without success.
Describe the Feature Request
Feature Request: Allow @Listen
and @Event
decorators to accept constants/variables for event names
Problem Description
Currently, the @Listen
decorator in Stencil only accepts string literals for event names, while the @Event
decorator's eventName
property has similar limitations. This prevents developers from using constants or variables consistently across both decorators, forcing duplication of event name strings throughout the codebase and increasing the risk of typos.
Current Behavior
const EVENTS = { CUSTOM_EVENT: 'customEvent' } as const;
// ❌ @Listen doesn't work with constants - compilation error
@Listen(EVENTS.CUSTOM_EVENT)
handleCustomEvent() { ... }
// ❌ @Event eventName doesn't work with constants in some cases
@Event({ eventName: EVENTS.CUSTOM_EVENT }) customEvent: EventEmitter; // May not work consistently
// ✅ Only string literals work reliably
@Listen('customEvent')
handleCustomEvent() { ... }
@Event({ eventName: 'customEvent' }) customEvent: EventEmitter;
Desired Behavior
const EVENTS = { CUSTOM_EVENT: 'customEvent' } as const;
// ✅ Should work - using constants consistently
@Listen(EVENTS.CUSTOM_EVENT)
handleCustomEvent() { ... }
@Event({ eventName: EVENTS.CUSTOM_EVENT }) customEvent: EventEmitter;
// ✅ Should work - using variables
const eventName = 'customEvent';
@Listen(eventName)
handleCustomEvent() { ... }
@Event({ eventName: eventName }) customEvent: EventEmitter;
// ✅ Alternative - factory function approach
@Listen(() => EVENTS.CUSTOM_EVENT)
handleCustomEvent() { ... }
@Event({ eventName: () => EVENTS.CUSTOM_EVENT }) customEvent: EventEmitter;
Use Case & Motivation
1. Avoid Typos:
// Current approach - prone to typos
@Event({ eventName: 'userLoginSuccess' }) loginSuccess: EventEmitter;
@Listen('userLoginSucces') // Typo! Missing 's'
handleLogin() { ... }
// Desired approach - typo-safe
const EVENTS = { USER_LOGIN_SUCCESS: 'userLoginSuccess' } as const;
@Event({ eventName: EVENTS.USER_LOGIN_SUCCESS }) loginSuccess: EventEmitter;
@Listen(EVENTS.USER_LOGIN_SUCCESS) // No typos possible
handleLogin() { ... }
2. Maintain DRY Principles:
// Single source of truth for event names
export const EVENTS = {
USER: {
LOGIN_SUCCESS: 'userLoginSuccess',
LOGIN_FAILURE: 'userLoginFailure',
LOGOUT: 'userLogout'
},
DATA: {
LOADED: 'dataLoaded',
ERROR: 'dataError',
REFRESH: 'dataRefresh'
}
} as const;
// Use consistently across components
@Event({ eventName: EVENTS.USER.LOGIN_SUCCESS }) loginSuccess: EventEmitter;
@Listen(EVENTS.USER.LOGIN_SUCCESS) handleLoginSuccess() { ... }
@Event({ eventName: EVENTS.DATA.LOADED }) dataLoaded: EventEmitter;
@Listen(EVENTS.DATA.LOADED) handleDataLoaded() { ... }
3. Better Refactoring:
When event names need to change, you only update them in one place instead of searching and replacing string literals throughout the codebase.
4. Cross-Component Consistency:
// Component A emits event
@Event({ eventName: EVENTS.USER.LOGIN_SUCCESS }) loginSuccess: EventEmitter;
// Component B listens to the same event
@Listen(EVENTS.USER.LOGIN_SUCCESS) handleUserLogin() { ... }
// Component C also listens
@Listen(EVENTS.USER.LOGIN_SUCCESS) onUserLogin() { ... }
Proposed Solution
Allow both decorators to accept constants/variables:
1. @Listen
decorator:
const eventName = 'myEvent';
@Listen(eventName)
@Listen(EVENTS.MY_EVENT)
@Listen(() => EVENTS.MY_EVENT) // factory function alternative
2. @Event
decorator:
@Event({ eventName: eventName })
@Event({ eventName: EVENTS.MY_EVENT })
@Event({ eventName: () => EVENTS.MY_EVENT }) // factory function alternative
Enhanced Decorator Implementation Example
// Enhanced @Listen decorator implementation
function Listen(
eventName: string | (() => string),
opts?: ListenOptions
): MethodDecorator {
return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// Resolve the event name if it's a function
const resolvedEventName = typeof eventName === 'function' ? eventName() : eventName;
// Continue with existing Stencil logic using resolvedEventName
// (This would integrate into Stencil's existing compilation process)
return applyListenDecorator(target, propertyKey, descriptor, resolvedEventName, opts);
};
}
// Enhanced @Event decorator implementation
function Event(opts: EventOptions & { eventName?: string | (() => string) }): PropertyDecorator {
return function(target: any, propertyKey: string) {
// Resolve the eventName if it's a function
const resolvedOpts = { ...opts };
if (opts.eventName && typeof opts.eventName === 'function') {
resolvedOpts.eventName = opts.eventName();
}
// Continue with existing Stencil logic using resolvedOpts
return applyEventDecorator(target, propertyKey, resolvedOpts);
};
}
// Usage - no syntax changes needed, just enhanced support
const EVENTS = {
MODAL_CLOSED: 'modalClosed',
USER_LOGIN: 'userLogin'
} as const;
@Component({ tag: 'my-component' })
export class MyComponent {
// Now works with functions that return event names
@Event({ eventName: () => EVENTS.MODAL_CLOSED, bubbles: true })
modalClosed: EventEmitter;
@Listen(() => EVENTS.USER_LOGIN, { target: 'window' })
handleUserLogin(event: CustomEvent) { ... }
// Still works with constants/variables
@Listen(EVENTS.MODAL_CLOSED)
handleModalClosed() { ... }
@Event({ eventName: EVENTS.USER_LOGIN })
userLogin: EventEmitter;
// Still works with string literals
@Listen('click')
handleClick() { ... }
@Event({ eventName: 'customEvent' })
customEvent: EventEmitter;
}
Type Definitions:
// Updated type definitions
interface ListenOptions {
target?: string;
capture?: boolean;
passive?: boolean;
}
interface EventOptions {
eventName?: string | (() => string);
composed?: boolean;
cancelable?: boolean;
bubbles?: boolean;
}
declare function Listen(
eventName: string | (() => string),
opts?: ListenOptions
): MethodDecorator;
declare function Event(
opts?: EventOptions
): PropertyDecorator;
This approach enhances the existing decorators to detect when a function is passed and resolve it during compilation, while maintaining backward compatibility with all existing usage patterns.
Implementation Considerations
The Stencil compiler would need to:
- Detect when non-string-literal values are passed to both decorators
- Resolve the actual string value during compilation for both
@Listen
and@Event({ eventName: ... })
- Handle imported constants from other modules
- Provide helpful error messages when values can't be statically resolved
- Ensure consistent behavior between both decorators
Benefits
- Type Safety: Leverage TypeScript's type system for event names
- DRY Principle: Single source of truth for event names across emitters and listeners
- Refactor Safety: Change event names in one place, affects both emission and listening
- IDE Support: Better autocomplete and refactoring tools for event names
- Consistency: Both
@Event
and@Listen
can use the same constant definitions - Cross-Component Safety: Guaranteed matching between event emitters and listeners
Real-World Example
// events.constants.ts
export const COMPONENT_EVENTS = {
MODAL: {
OPENED: 'modalOpened',
CLOSED: 'modalClosed',
CONFIRMED: 'modalConfirmed'
},
FORM: {
SUBMITTED: 'formSubmitted',
VALIDATED: 'formValidated',
RESET: 'formReset'
}
} as const;
// modal.component.ts
import { COMPONENT_EVENTS } from './events.constants';
@Component({ tag: 'my-modal' })
export class MyModal {
@Event({ eventName: COMPONENT_EVENTS.MODAL.OPENED }) modalOpened: EventEmitter;
@Event({ eventName: COMPONENT_EVENTS.MODAL.CLOSED }) modalClosed: EventEmitter;
}
// app.component.ts
import { COMPONENT_EVENTS } from './events.constants';
@Component({ tag: 'my-app' })
export class MyApp {
@Listen(COMPONENT_EVENTS.MODAL.OPENED)
handleModalOpened() { ... }
@Listen(COMPONENT_EVENTS.MODAL.CLOSED)
handleModalClosed() { ... }
}
Current Workarounds
Developers currently must:
- Use string literals everywhere (prone to typos)
- Use constants inconsistently (constants for
@Event
, literals for@Listen
) - Add comments to link constants with string literals:
// Emit COMPONENT_EVENTS.MODAL.OPENED @Event({ eventName: 'modalOpened' }) modalOpened: EventEmitter; // Listen to COMPONENT_EVENTS.MODAL.OPENED @Listen('modalOpened') handleModalOpened() { ... }
This feature would eliminate these workarounds and provide a more robust, maintainable development experience for both event emission and listening.
Describe the Use Case
Use Case & Motivation
- Avoid Typos:
// Current approach - prone to typos
@Event({ eventName: 'userLoginSuccess' }) loginSuccess: EventEmitter;
@Listen('userLoginSucces') // Typo! Missing 's'
handleLogin() { ... }
// Desired approach - typo-safe
const EVENTS = { USER_LOGIN_SUCCESS: 'userLoginSuccess' } as const;
@Event({ eventName: EVENTS.USER_LOGIN_SUCCESS }) loginSuccess: EventEmitter;
@Listen(EVENTS.USER_LOGIN_SUCCESS) // No typos possible
handleLogin() { ... }
- Maintain DRY Principles:
// Single source of truth for event names
export const EVENTS = {
USER: {
LOGIN_SUCCESS: 'userLoginSuccess',
LOGIN_FAILURE: 'userLoginFailure',
LOGOUT: 'userLogout'
},
DATA: {
LOADED: 'dataLoaded',
ERROR: 'dataError',
REFRESH: 'dataRefresh'
}
} as const;
// Use consistently across components
@Event({ eventName: EVENTS.USER.LOGIN_SUCCESS }) loginSuccess: EventEmitter;
@Listen(EVENTS.USER.LOGIN_SUCCESS) handleLoginSuccess() { ... }
@Event({ eventName: EVENTS.DATA.LOADED }) dataLoaded: EventEmitter;
@Listen(EVENTS.DATA.LOADED) handleDataLoaded() { ... }
-
Better Refactoring:
When event names need to change, you only update them in one place instead of searching and replacing string literals throughout the codebase. -
Cross-Component Consistency:
// Component A emits event
@Event({ eventName: EVENTS.USER.LOGIN_SUCCESS }) loginSuccess: EventEmitter;
// Component B listens to the same event
@Listen(EVENTS.USER.LOGIN_SUCCESS) handleUserLogin() { ... }
// Component C also listens
@Listen(EVENTS.USER.LOGIN_SUCCESS) onUserLogin() { ... }
Describe Preferred Solution
Proposed Solution
Allow both decorators to accept constants/variables:
@Listen
decorator:
const eventName = 'myEvent';
@Listen(eventName)
@Listen(EVENTS.MY_EVENT)
@Listen(() => EVENTS.MY_EVENT) // factory function alternative
@Event
decorator:
@Event({ eventName: eventName })
@Event({ eventName: EVENTS.MY_EVENT })
@Event({ eventName: () => EVENTS.MY_EVENT }) // factory function alternative
Enhanced Decorator Implementation Example
// Enhanced @Listen decorator implementation
function Listen(
eventName: string | (() => string),
opts?: ListenOptions
): MethodDecorator {
return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// Resolve the event name if it's a function
const resolvedEventName = typeof eventName === 'function' ? eventName() : eventName;
// Continue with existing Stencil logic using resolvedEventName
// (This would integrate into Stencil's existing compilation process)
return applyListenDecorator(target, propertyKey, descriptor, resolvedEventName, opts);
};
}
// Enhanced @Event decorator implementation
function Event(opts: EventOptions & { eventName?: string | (() => string) }): PropertyDecorator {
return function(target: any, propertyKey: string) {
// Resolve the eventName if it's a function
const resolvedOpts = { ...opts };
if (opts.eventName && typeof opts.eventName === 'function') {
resolvedOpts.eventName = opts.eventName();
}
// Continue with existing Stencil logic using resolvedOpts
return applyEventDecorator(target, propertyKey, resolvedOpts);
};
}
// Usage - no syntax changes needed, just enhanced support
const EVENTS = {
MODAL_CLOSED: 'modalClosed',
USER_LOGIN: 'userLogin'
} as const;
@Component({ tag: 'my-component' })
export class MyComponent {
// Now works with functions that return event names
@Event({ eventName: () => EVENTS.MODAL_CLOSED, bubbles: true })
modalClosed: EventEmitter;
@Listen(() => EVENTS.USER_LOGIN, { target: 'window' })
handleUserLogin(event: CustomEvent) { ... }
// Still works with constants/variables
@Listen(EVENTS.MODAL_CLOSED)
handleModalClosed() { ... }
@Event({ eventName: EVENTS.USER_LOGIN })
userLogin: EventEmitter;
// Still works with string literals
@Listen('click')
handleClick() { ... }
@Event({ eventName: 'customEvent' })
customEvent: EventEmitter;
}
Type Definitions:
// Updated type definitions
interface ListenOptions {
target?: string;
capture?: boolean;
passive?: boolean;
}
interface EventOptions {
eventName?: string | (() => string);
composed?: boolean;
cancelable?: boolean;
bubbles?: boolean;
}
declare function Listen(
eventName: string | (() => string),
opts?: ListenOptions
): MethodDecorator;
declare function Event(
opts?: EventOptions
): PropertyDecorator;
This approach enhances the existing decorators to detect when a function is passed and resolve it during compilation, while maintaining backward compatibility with all existing usage patterns.
Describe Alternatives
No response
Related Code
No response
Additional Information
No response