Inter-namespace communication with LWC

Comment from OP:

Neither of these approaches work across namespaces because the window object for each namespace is a different instance, as part of the Locker Service.

See the answer from the OP that explains how the only commonality between the two window objects is the addition of event listeners and the sending of DOM events; it is currently possible (including in the Summer '20 release) to tunnel DOM events across the namespace boundary.

added:

Problem:

LWC is importing different resources (apex/static/modules/wired etc) in different ways. Importing modules is identical to standard ES6 imports. However when you import static resource, it is actually wrapped inside a named function and is executed when we invoke it in loadScript.

You will be able to upload it as static resource but will not compile at run time (because of export statement) during the import because of which no named function will be created (took a hell lot of debugging) and loadScript will directly go to catch statement. For easy understanding try to save below function in any LWC component:

staticResourceLoad() {
    const mydouble = (num) => {
        return num * 2;
    };
    export { mydouble };
}

This will not even compile (Parsing error: 'import' and 'export' may only appear at the top level). So, when you want to load a module as a static resource, you need to do below changes in JS file - most importantly you need to define a variable on window so that you have access to that variable after the file is loaded.


Solution:

You cannot use the module as is in static resource. You need to implement it using self-invoking function and define global variable (like pubsub) and assign all the functions needed (like samePageRef) and return it so that it can be assigned to different var if needed.

Below is the pubsub which can be used for static resource:

(function() {
    window.pubsub = {};
    /**
 * A basic pub-sub mechanism for sibling component communication
 *
 * TODO - adopt standard flexipage sibling communication mechanism when it's available.
 */

    let events = {};

    /**
 * Confirm that two page references have the same attributes
 * @param {object} pageRef1 - The first page reference
 * @param {object} pageRef2 - The second page reference
 */
    pubsub.samePageRef = (pageRef1, pageRef2) => {
        const obj1 = pageRef1.attributes;
        const obj2 = pageRef2.attributes;
        return Object.keys(obj1).concat(Object.keys(obj2)).every((key) => {
            return obj1[key] === obj2[key];
        });
    };

    /**
 * Registers a callback for an event
 * @param {string} eventName - Name of the event to listen for.
 * @param {function} callback - Function to invoke when said event is fired.
 * @param {object} thisArg - The value to be passed as the this parameter to the callback function is bound.
 */
    pubsub.registerListener = (eventName, callback, thisArg) => {
        // Checking that the listener has a pageRef property. We rely on that property for filtering purpose in fireEvent()
        if (!thisArg.pageRef) {
            throw new Error(
                'pubsub listeners need a "@wire(CurrentPageReference) pageRef" property'
            );
        }

        if (!events[eventName]) {
            events[eventName] = [];
        }
        const duplicate = events[eventName].find((listener) => {
            return listener.callback === callback && listener.thisArg === thisArg;
        });
        if (!duplicate) {
            events[eventName].push({ callback, thisArg });
        }
    };

    /**
 * Unregisters a callback for an event
 * @param {string} eventName - Name of the event to unregister from.
 * @param {function} callback - Function to unregister.
 * @param {object} thisArg - The value to be passed as the this parameter to the callback function is bound.
 */
    pubsub.unregisterListener = (eventName, callback, thisArg) => {
        if (events[eventName]) {
            events[eventName] = events[eventName].filter(
                (listener) => listener.callback !== callback || listener.thisArg !== thisArg
            );
        }
    };

    /**
 * Unregisters all event listeners bound to an object.
 * @param {object} thisArg - All the callbacks bound to this object will be removed.
 */
    pubsub.unregisterAllListeners = (thisArg) => {
        Object.keys(events).forEach((eventName) => {
            events[eventName] = events[eventName].filter(
                (listener) => listener.thisArg !== thisArg
            );
        });
    };

    /**
 * Fires an event to listeners.
 * @param {object} pageRef - Reference of the page that represents the event scope.
 * @param {string} eventName - Name of the event to fire.
 * @param {*} payload - Payload of the event to fire.
 */
    pubsub.fireEvent = (pageRef, eventName, payload) => {
        if (events[eventName]) {
            const listeners = events[eventName];
            listeners.forEach((listener) => {
                if (samePageRef(pageRef, listener.thisArg.pageRef)) {
                    try {
                        listener.callback.call(listener.thisArg, payload);
                    } catch (error) {
                        // fail silently
                    }
                }
            });
        }
    };
    return pubsub;
})();

For importing:

import pubsubMod from '@salesforce/resourceUrl/pubsub';

For init:

loadScript(this, pubsubMod)
            .then(() => {
                this._pubsubInit = true;
                // Tentatively register, in case the page reference is already resolved
                pubsub.registerListener('contactSelected', this.handleContactSelected, this);
            })
            .catch((error) => {
                this.dispatchEvent(
                    new ShowToastEvent({
                        title: 'Error loading pubsub',
                        message: error.message,
                        variant: 'error'
                    })
                );
            });

Note that pubsubMod refers to resource and pubsub refers to global var window.pubsub

sample code for handling:

handleContactSelected(val) {
    console.log('val => ', val);
}

OPTION 2:

As you need a temporary solution until UI Message service is available, I think you can use javascript variable change handlers at window scope as a work around. I created playground link for understanding. As it will be at window scope, I think it will work for cross-namespace (I did not test it though)

Below is the sample code:

mytargetcomp.js:

connectedCallback() {
    window.myvar = {
        aInternal: 10,
        aListener: function(val) {},
        set a(val) {
            this.aInternal = val;
            this.aListener(val);
        },
        get a() {
            return this.aInternal;
        },
        registerListener: function(listener) {
            this.aListener = listener;
        }
    };
    window.myvar.registerListener(function(val) {
        console.log('Someone changed the value of myvar to ', val);
    });
}

corens__mysourcecomp.js:

refreshData() {
    window.myvar.a = 'some data ' + new Date().getTime();
}

This is when you want to pass data from corens__mytargetcomp to mycomp.

You can implement and invoke refreshData from any component and whichever component implements change handler should be able to handle new data.


After a lot of messing about I came to the following conclusion: the only way that I can find to allow LWCs from different package/namespaces to communicate is using DOM events.

The following should be noted:

  1. As per @salesforce-sas's answer, you cannot use a whole "module" as a static resource since the loadScript functionality is incompatible with this approach.
  2. It is not possible to use the pubsub code as-is (without the export statement) since each package has its own instance of the window object; because of this, the "events" map within window.pubsub is not shared, but rather is unique to the package. This then means that the fireEvent function does not work appropriately since the map only includes listeners registered within the namespace.
  3. Use of the Salesforce CustomEvent DOM event does work, though passing the page ref goes a bit against the documentation which says that "the CustomEvent interface imposes no type requirements or structure on the detail property. However it’s important to send only primitive data". This is, I believe, not an issue in this case since it is only the pubsub code that sees the "page ref" part of the detail property (and doesn't try to update it).

The complete working pubsub code from my PoC (I also have a package that contains this code plus an LWC that uses it, and an org on which I created a second LWC using this script) is as follows:

/**
 * A basic pub-sub mechanism for sibling component communication that works
 * across package boundaries.
 */
if (!window.pubsub) {
    window.pubsub = (function () {
        const _events = {};
        const _registered = {};

        const samePageRef = (pageRef1, pageRef2) => {
            const obj1 = pageRef1.attributes;
            const obj2 = pageRef2.attributes;
            return Object.keys(obj1)
                .concat(Object.keys(obj2))
                .every(key => {
                    return obj1[key] === obj2[key];
                });
        };

        /**
         * Registers a callback for an event.
         *
         * @param {string} eventName - Name of the event to listen for.
         * @param {function} callback - Function to invoke when said event is fired.
         * @param {object} thisArg - The value to be passed as the this parameter to the callback function is bound.
         */
        const registerListener = (eventName, callback, thisArg) => {
            // Checking that the listener has a pageRef property. We rely on that property for filtering purpose in fireEvent()
            if (!thisArg.pageRef) {
                throw new Error(
                    'pubsub listeners need a "@wire(CurrentPageReference) pageRef" property',
                );
            }

            if (!_events[eventName]) {
                _events[eventName] = [];
            }

            const foundDuplicate = _events[eventName].find(listener => {
                return listener.callback === callback && listener.thisArg === thisArg;
            });

            if (!foundDuplicate) {
                _events[eventName].push({callback, thisArg});
            }

            if (!_registered[eventName]) {
                window.addEventListener(eventName, function (event) {
                    if (event.type === eventName) {
                        const listeners = _events[eventName];

                        listeners.forEach(listener => {
                            if (samePageRef(event.detail.pageRef, listener.thisArg.pageRef)) {
                                try {
                                    listener.callback.call(listener.thisArg, event.detail.payload);
                                } catch (error) {
                                    // fail silently
                                }
                            }
                        });
                    }
                });

                _registered[eventName] = true;
            }
        };

        /**
         * Unregisters a callback for an event.
         *
         * @param {string} eventName - Name of the event to unregister from.
         * @param {function} callback - Function to unregister.
         * @param {object} thisArg - The value to be passed as the this parameter to the callback function is bound.
         */
        const unregisterListener = (eventName, callback, thisArg) => {
            if (_events[eventName]) {
                _events[eventName] = _events[eventName].filter(
                    listener =>
                        listener.callback !== callback || listener.thisArg !== thisArg
                );
            }
        };

        /**
         * Unregisters all event listeners bound to an object.
         *
         * @param {object} thisArg - All the callbacks bound to this object will be removed.
         */
        const unregisterAllListeners = thisArg => {
            Object.keys(_events).forEach(eventName => {
                _events[eventName] = _events[eventName].filter(
                    listener => listener.thisArg !== thisArg,
                );
            });
        };

        /**
         * Fires an event to listeners.
         *
         * @param {object} pageRef - Reference of the page that represents the event scope.
         * @param {string} eventName - Name of the event to fire.
         * @param {*} payload - Payload of the event to fire.
         */
        const fireEvent = (pageRef, eventName, payload) => {
            const event = new CustomEvent(eventName, {detail: {pageRef: pageRef, payload: payload}});

            window.dispatchEvent(event);
        };

        return Object.freeze({
            registerListener: registerListener,
            unregisterListener: unregisterListener,
            unregisterAllListeners: unregisterAllListeners,
            fireEvent: fireEvent
        });
    })();
}

This isn't fully polished - it doesn't cleanly handle unregistering the underlying DOM events, but does enough to minimize spurious processing of events after unregistration.

Importantly, however, using this I am able to successfully communicate between components in different packages/namespaces.

UPDATE:

Whilst the official means of communicating across the DOM, i.e. the Lightning Message Service, has GAed in Summer '20 release, it appears that this cannot be used with Lightning Communities and message channels cannot be bundled in App Exchange packages (which we need to do).

We have also discovered a minor issue caused by the use of dynamically added listeners (which does go against Salesforce recommendation) - switching lightning pages can unexpectedly leave listeners listening and components still active. Something else to work around.