* Migrating Core Salesforce Skills * Updating pr comments * updat reference * Updating a skill * Migrating Datacloud skills * Migrating Industries cloud skills * Validating - skills fixing --------- Co-authored-by: Sandip Kumar Yadav <sandipkumar.yadav+sfemu@salesforce.com>
22 KiB
Lightning Message Service (LMS) Guide
Complete guide to cross-DOM component communication using Lightning Message Service.
Table of Contents
- Overview
- When to Use LMS
- Message Channel Setup
- Publishing Messages
- Subscribing to Messages
- Scopes
- Advanced Patterns
- Best Practices
- Troubleshooting
Overview
Lightning Message Service (LMS) enables communication between components across different DOM contexts:
- Lightning Web Components (LWC)
- Aura Components
- Visualforce pages (in Lightning Experience)
Key Benefits:
- Cross-DOM communication (Shadow DOM boundaries)
- Loosely coupled components
- Publish-subscribe pattern
- Type-safe messaging with message channels
When to Use LMS
| Use Case | Recommended Pattern |
|---|---|
| Parent → Child | @api properties (simple, direct) |
| Child → Parent | Custom Events (simple, direct) |
| Sibling → Sibling (same hierarchy) | Parent mediator + Custom Events |
| Cross-DOM communication | Lightning Message Service |
| App Builder page components | Lightning Message Service |
| Aura ↔ LWC communication | Lightning Message Service |
| Visualforce ↔ LWC (in LEX) | Lightning Message Service |
Rule of Thumb: Use LMS when components cannot directly reference each other or cross DOM boundaries.
Message Channel Setup
1. Create Message Channel Metadata
Lightning Message Channels are metadata files that define the message schema.
File: force-app/main/default/messageChannels/AccountSelected__c.messageChannel-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningMessageChannel xmlns="http://soap.sforce.com/2006/04/metadata">
<description>Message channel for account selection events</description>
<isExposed>true</isExposed>
<lightningMessageFields>
<description>Account ID</description>
<fieldName>accountId</fieldName>
</lightningMessageFields>
<lightningMessageFields>
<description>Account Name</description>
<fieldName>accountName</fieldName>
</lightningMessageFields>
<lightningMessageFields>
<description>Source component identifier</description>
<fieldName>source</fieldName>
</lightningMessageFields>
<masterLabel>Account Selected</masterLabel>
</LightningMessageChannel>
2. Deploy Message Channel
sf project deploy start -m LightningMessageChannel:AccountSelected__c
3. Import Message Channel in Component
import ACCOUNT_SELECTED_CHANNEL from '@salesforce/messageChannel/AccountSelected__c';
Publishing Messages
Basic Publisher Pattern
// accountPublisher.js
import { LightningElement, wire } from 'lwc';
import { publish, MessageContext } from 'lightning/messageService';
import ACCOUNT_SELECTED_CHANNEL from '@salesforce/messageChannel/AccountSelected__c';
export default class AccountPublisher extends LightningElement {
@wire(MessageContext)
messageContext;
handleAccountClick(event) {
const accountId = event.target.dataset.id;
const accountName = event.target.dataset.name;
// Create payload
const payload = {
accountId: accountId,
accountName: accountName,
source: 'accountPublisher'
};
// Publish message
publish(this.messageContext, ACCOUNT_SELECTED_CHANNEL, payload);
}
}
<!-- accountPublisher.html -->
<template>
<div class="slds-card">
<div class="slds-card__header">
<h2 class="slds-text-heading_medium">Account List</h2>
</div>
<div class="slds-card__body">
<template for:each={accounts} for:item="account">
<div key={account.Id}
class="slds-box slds-m-around_small"
data-id={account.Id}
data-name={account.Name}
onclick={handleAccountClick}>
{account.Name}
</div>
</template>
</div>
</div>
</template>
Publisher with Conditional Logic
handlePublish(accountData) {
// Validate before publishing
if (!this.messageContext) {
console.error('MessageContext not initialized');
return;
}
if (!accountData || !accountData.Id) {
console.error('Invalid account data');
return;
}
const payload = {
accountId: accountData.Id,
accountName: accountData.Name,
source: this.componentName,
timestamp: Date.now()
};
publish(this.messageContext, ACCOUNT_SELECTED_CHANNEL, payload);
// Optional: Show toast confirmation
this.dispatchEvent(new ShowToastEvent({
title: 'Selection Published',
message: `Account "${accountData.Name}" selected`,
variant: 'success'
}));
}
Subscribing to Messages
Basic Subscriber Pattern
// accountSubscriber.js
import { LightningElement, wire } from 'lwc';
import { subscribe, unsubscribe, MessageContext, APPLICATION_SCOPE } from 'lightning/messageService';
import ACCOUNT_SELECTED_CHANNEL from '@salesforce/messageChannel/AccountSelected__c';
export default class AccountSubscriber extends LightningElement {
subscription = null;
selectedAccountId;
selectedAccountName;
@wire(MessageContext)
messageContext;
connectedCallback() {
this.subscribeToChannel();
}
disconnectedCallback() {
this.unsubscribeFromChannel();
}
subscribeToChannel() {
if (!this.subscription) {
this.subscription = subscribe(
this.messageContext,
ACCOUNT_SELECTED_CHANNEL,
(message) => this.handleMessage(message),
{ scope: APPLICATION_SCOPE }
);
}
}
unsubscribeFromChannel() {
unsubscribe(this.subscription);
this.subscription = null;
}
handleMessage(message) {
this.selectedAccountId = message.accountId;
this.selectedAccountName = message.accountName;
console.log('Message received from:', message.source);
console.log('Account ID:', message.accountId);
}
}
<!-- accountSubscriber.html -->
<template>
<div class="slds-card">
<div class="slds-card__header">
<h2 class="slds-text-heading_medium">Selected Account</h2>
</div>
<div class="slds-card__body">
<template lwc:if={selectedAccountId}>
<dl class="slds-dl_horizontal">
<dt class="slds-dl_horizontal__label">Account ID:</dt>
<dd class="slds-dl_horizontal__detail">{selectedAccountId}</dd>
<dt class="slds-dl_horizontal__label">Account Name:</dt>
<dd class="slds-dl_horizontal__detail">{selectedAccountName}</dd>
</dl>
</template>
<template lwc:else>
<p class="slds-text-color_weak">No account selected</p>
</template>
</div>
</div>
</template>
Subscriber with Filtering
handleMessage(message) {
// Ignore messages from this component (avoid self-updates)
if (message.source === this.componentName) {
return;
}
// Filter by specific conditions
if (message.accountId && message.accountId.startsWith('001')) {
this.selectedAccountId = message.accountId;
this.selectedAccountName = message.accountName;
// Fetch additional data if needed
this.loadAccountDetails(message.accountId);
}
}
async loadAccountDetails(accountId) {
try {
const data = await getAccountDetails({ accountId });
this.accountDetails = data;
} catch (error) {
this.handleError(error);
}
}
Scopes
LMS supports two subscription scopes:
| Scope | Behavior | Use Case |
|---|---|---|
APPLICATION_SCOPE |
Receive messages from entire app | Cross-page communication, global state |
undefined (default) |
Receive messages only within active tab | Tab-specific communication |
Application Scope Example
import { APPLICATION_SCOPE } from 'lightning/messageService';
subscribe(
this.messageContext,
ACCOUNT_SELECTED_CHANNEL,
(message) => this.handleMessage(message),
{ scope: APPLICATION_SCOPE }
);
Tab Scope Example
// No scope specified = tab scope only
subscribe(
this.messageContext,
ACCOUNT_SELECTED_CHANNEL,
(message) => this.handleMessage(message)
);
Advanced Patterns
1. Multiple Subscriptions
export default class MultiSubscriber extends LightningElement {
accountSubscription = null;
contactSubscription = null;
@wire(MessageContext) messageContext;
connectedCallback() {
// Subscribe to account channel
this.accountSubscription = subscribe(
this.messageContext,
ACCOUNT_SELECTED_CHANNEL,
(message) => this.handleAccountMessage(message),
{ scope: APPLICATION_SCOPE }
);
// Subscribe to contact channel
this.contactSubscription = subscribe(
this.messageContext,
CONTACT_SELECTED_CHANNEL,
(message) => this.handleContactMessage(message),
{ scope: APPLICATION_SCOPE }
);
}
disconnectedCallback() {
unsubscribe(this.accountSubscription);
unsubscribe(this.contactSubscription);
this.accountSubscription = null;
this.contactSubscription = null;
}
handleAccountMessage(message) {
// Handle account-specific logic
}
handleContactMessage(message) {
// Handle contact-specific logic
}
}
2. Publish-Subscribe in Same Component
export default class PublisherSubscriber extends LightningElement {
subscription = null;
@wire(MessageContext) messageContext;
connectedCallback() {
// Subscribe to messages from OTHER components
this.subscription = subscribe(
this.messageContext,
ACCOUNT_SELECTED_CHANNEL,
(message) => this.handleMessage(message),
{ scope: APPLICATION_SCOPE }
);
}
disconnectedCallback() {
unsubscribe(this.subscription);
}
handleMessage(message) {
// Filter out own messages
if (message.source === 'myComponent') {
return;
}
// Process external messages
this.selectedAccountId = message.accountId;
}
handleLocalSelection(event) {
const accountId = event.detail.id;
// Publish for other components
publish(this.messageContext, ACCOUNT_SELECTED_CHANNEL, {
accountId,
source: 'myComponent'
});
// Update own state directly (don't rely on subscription)
this.selectedAccountId = accountId;
}
}
3. Conditional Subscription
export default class ConditionalSubscriber extends LightningElement {
@api enableLiveUpdates = false;
subscription = null;
@wire(MessageContext) messageContext;
connectedCallback() {
if (this.enableLiveUpdates) {
this.subscribeToChannel();
}
}
@api
toggleLiveUpdates(enabled) {
this.enableLiveUpdates = enabled;
if (enabled) {
this.subscribeToChannel();
} else {
this.unsubscribeFromChannel();
}
}
subscribeToChannel() {
if (!this.subscription) {
this.subscription = subscribe(
this.messageContext,
ACCOUNT_SELECTED_CHANNEL,
(message) => this.handleMessage(message),
{ scope: APPLICATION_SCOPE }
);
}
}
unsubscribeFromChannel() {
if (this.subscription) {
unsubscribe(this.subscription);
this.subscription = null;
}
}
}
4. Message Buffering
export default class MessageBuffer extends LightningElement {
messageQueue = [];
isProcessing = false;
handleMessage(message) {
this.messageQueue.push(message);
this.processQueue();
}
async processQueue() {
if (this.isProcessing || this.messageQueue.length === 0) {
return;
}
this.isProcessing = true;
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
await this.processMessage(message);
}
this.isProcessing = false;
}
async processMessage(message) {
// Simulate async processing
return new Promise(resolve => {
setTimeout(() => {
this.selectedAccountId = message.accountId;
resolve();
}, 100);
});
}
}
Best Practices
1. Always Unsubscribe
disconnectedCallback() {
// CRITICAL: Prevent memory leaks
if (this.subscription) {
unsubscribe(this.subscription);
this.subscription = null;
}
}
2. Validate MessageContext
handlePublish(data) {
if (!this.messageContext) {
console.warn('MessageContext not available');
return;
}
publish(this.messageContext, CHANNEL, data);
}
3. Use Descriptive Payloads
// BAD - Unclear payload
publish(this.messageContext, CHANNEL, { id: '001xxx' });
// GOOD - Clear, descriptive payload
publish(this.messageContext, CHANNEL, {
accountId: '001xxx000003DGQ',
accountName: 'Acme Corp',
source: 'accountList',
timestamp: Date.now(),
metadata: {
action: 'selected',
view: 'list'
}
});
4. Document Message Channels
/**
* Publishes account selection event to AccountSelected__c channel
* @param {Object} payload
* @param {String} payload.accountId - Salesforce Account ID
* @param {String} payload.accountName - Account Name
* @param {String} payload.source - Component identifier
*/
publishAccountSelection(payload) {
publish(this.messageContext, ACCOUNT_SELECTED_CHANNEL, payload);
}
5. Error Handling
handleMessage(message) {
try {
if (!message || !message.accountId) {
throw new Error('Invalid message payload');
}
this.selectedAccountId = message.accountId;
this.loadAccountDetails(message.accountId);
} catch (error) {
console.error('Error processing message:', error);
this.dispatchEvent(new ShowToastEvent({
title: 'Error',
message: 'Failed to process message',
variant: 'error'
}));
}
}
Troubleshooting
Issue: Messages Not Received
Checklist:
-
Is
MessageContextwired correctly?@wire(MessageContext) messageContext; -
Is subscription active?
console.log('Subscription:', this.subscription); // Should not be null -
Is the message channel deployed?
sf project deploy start -m LightningMessageChannel -
Are publisher and subscriber using the same channel?
// Both should import the same channel import CHANNEL from '@salesforce/messageChannel/AccountSelected__c'; -
Is the scope correct?
// For cross-page: APPLICATION_SCOPE // For same page: no scope (default)
Issue: Memory Leaks
Cause: Not unsubscribing in disconnectedCallback()
Fix:
disconnectedCallback() {
unsubscribe(this.subscription);
this.subscription = null;
}
Issue: Self-Updates
Cause: Component receives its own published messages
Fix: Filter by source
handleMessage(message) {
if (message.source === this.componentName) {
return; // Ignore own messages
}
// Process message
}
Testing LMS Components
Mock MessageContext
// testUtils.js
export const createMessageContextMock = () => {
return jest.fn();
};
export const mockPublish = jest.fn();
export const mockSubscribe = jest.fn();
export const mockUnsubscribe = jest.fn();
jest.mock('lightning/messageService', () => ({
publish: mockPublish,
subscribe: mockSubscribe,
unsubscribe: mockUnsubscribe,
MessageContext: Symbol('MessageContext'),
APPLICATION_SCOPE: Symbol('APPLICATION_SCOPE')
}), { virtual: true });
Test Publisher
import { createElement } from 'lwc';
import AccountPublisher from 'c/accountPublisher';
import { publish } from 'lightning/messageService';
import ACCOUNT_SELECTED_CHANNEL from '@salesforce/messageChannel/AccountSelected__c';
jest.mock('lightning/messageService');
describe('c-account-publisher', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('publishes account selection', () => {
const element = createElement('c-account-publisher', {
is: AccountPublisher
});
document.body.appendChild(element);
// Trigger selection
const accountCard = element.shadowRoot.querySelector('[data-id="001xxx"]');
accountCard.click();
// Assert publish was called
expect(publish).toHaveBeenCalledWith(
expect.anything(),
ACCOUNT_SELECTED_CHANNEL,
expect.objectContaining({
accountId: '001xxx'
})
);
});
});
Test Subscriber
import { createElement } from 'lwc';
import AccountSubscriber from 'c/accountSubscriber';
import { subscribe } from 'lightning/messageService';
jest.mock('lightning/messageService');
describe('c-account-subscriber', () => {
let messageHandler;
beforeEach(() => {
subscribe.mockImplementation((context, channel, handler, options) => {
messageHandler = handler;
return { subscription: 'mock-subscription' };
});
});
it('subscribes on connected', () => {
const element = createElement('c-account-subscriber', {
is: AccountSubscriber
});
document.body.appendChild(element);
expect(subscribe).toHaveBeenCalled();
});
it('handles incoming message', async () => {
const element = createElement('c-account-subscriber', {
is: AccountSubscriber
});
document.body.appendChild(element);
// Simulate message
messageHandler({
accountId: '001xxx',
accountName: 'Acme Corp'
});
await Promise.resolve();
const accountName = element.shadowRoot.querySelector('.account-name');
expect(accountName.textContent).toBe('Acme Corp');
});
});
Complete Example: Account-Contact Sync
Message Channel
<!-- ContactSelected__c.messageChannel-meta.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<LightningMessageChannel xmlns="http://soap.sforce.com/2006/04/metadata">
<description>Contact selection messaging</description>
<isExposed>true</isExposed>
<lightningMessageFields>
<fieldName>contactId</fieldName>
</lightningMessageFields>
<lightningMessageFields>
<fieldName>contactName</fieldName>
</lightningMessageFields>
<lightningMessageFields>
<fieldName>accountId</fieldName>
</lightningMessageFields>
<masterLabel>Contact Selected</masterLabel>
</LightningMessageChannel>
Publisher Component
// contactList.js
import { LightningElement, api, wire } from 'lwc';
import { publish, MessageContext } from 'lightning/messageService';
import CONTACT_SELECTED from '@salesforce/messageChannel/ContactSelected__c';
import getContacts from '@salesforce/apex/ContactController.getContacts';
export default class ContactList extends LightningElement {
@api accountId;
contacts;
@wire(MessageContext) messageContext;
@wire(getContacts, { accountId: '$accountId' })
wiredContacts({ data, error }) {
if (data) {
this.contacts = data;
}
}
handleContactSelect(event) {
const contactId = event.currentTarget.dataset.id;
const contact = this.contacts.find(c => c.Id === contactId);
publish(this.messageContext, CONTACT_SELECTED, {
contactId: contact.Id,
contactName: contact.Name,
accountId: this.accountId
});
}
}
Subscriber Component
// contactDetails.js
import { LightningElement, wire } from 'lwc';
import { subscribe, MessageContext, APPLICATION_SCOPE } from 'lightning/messageService';
import CONTACT_SELECTED from '@salesforce/messageChannel/ContactSelected__c';
import getContactDetails from '@salesforce/apex/ContactController.getContactDetails';
export default class ContactDetails extends LightningElement {
subscription = null;
contactId;
contactDetails;
@wire(MessageContext) messageContext;
connectedCallback() {
this.subscription = subscribe(
this.messageContext,
CONTACT_SELECTED,
(message) => this.handleContactSelected(message),
{ scope: APPLICATION_SCOPE }
);
}
disconnectedCallback() {
unsubscribe(this.subscription);
}
async handleContactSelected(message) {
this.contactId = message.contactId;
try {
this.contactDetails = await getContactDetails({
contactId: message.contactId
});
} catch (error) {
console.error('Error loading contact details:', error);
}
}
}
Related Resources
- component-patterns.md - Parent-child communication
- jest-testing.md - Testing LMS components
- Official LMS Documentation