afv-library/skills/experience-lwc-generate/references/lms-guide.md

22 KiB

Lightning Message Service (LMS) Guide

Complete guide to cross-DOM component communication using Lightning Message Service.


Table of Contents

  1. Overview
  2. When to Use LMS
  3. Message Channel Setup
  4. Publishing Messages
  5. Subscribing to Messages
  6. Scopes
  7. Advanced Patterns
  8. Best Practices
  9. 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:

  1. Is MessageContext wired correctly?

    @wire(MessageContext) messageContext;
    
  2. Is subscription active?

    console.log('Subscription:', this.subscription); // Should not be null
    
  3. Is the message channel deployed?

    sf project deploy start -m LightningMessageChannel
    
  4. Are publisher and subscriber using the same channel?

    // Both should import the same channel
    import CHANNEL from '@salesforce/messageChannel/AccountSelected__c';
    
  5. 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);
        }
    }
}