7

I need to develop two components, in two separate namespaces, that can interact with each other. As per the product documentation it is not possible (yet) to write a component that uses a component from a different namespace.

I am therefore looking at a way of doing this that is compatible with the LWC framework.

As part of a separate discussion with a Salesforce Product Manager, it was suggested that I could consider using the new LWC Message Channel stuff. However, this is only Winter '20 developer preview at this stage and therefore not something I can use at this point.

The "not quite product" approach available until LWC Message Channels arrive as GA is the "pubsub" approach as discussed in further product documentation.

Note that the pubsub approach, as it stands in the example code, cannot be used directly since it is defined as a module and the documentation (see first link) says modules cannot be used cross-namespace. Instead the idea is to make it a static resource and load it as if it were a third-party library.

Knowing that LWC can really bite you in the posterior if you are doing anything slightly out of the ordinary I decided to create a simple PoC. The "core" package includes the "pubsub" code as a static resource, which gets imported like this in the "core" package component:

import {loadScript} from 'lightning/platformResourceLoader';
import pubsub from '@salesforce/resourceUrl/pubsub';

The code uses loadScript to resolve the imported URL (pubsub) into JavaScript available for use in the component.

The "secondary" package does this almost identically; the only difference is that this time the import looks like:

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

where "corens" is the namespace for the "core" package.

This appears to deploy to the org fine. However, when executing the code it doesn't behave as expected. The code for handling the registration of listeners during the loading of the resource fails:

renderedCallback() {
    if (!this._pubsubInit) {
        // Mark that this loading is complete
        this._pubsubInit = true;

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

Something I can't see makes the loadScript Promise call the catch instead of the then, but the "error" is undefined (and therefore the toast event cannot be created due to an unhandled JavaScript error attempting to access "error.message").

If I load a script from the current component's namespace, it seems to work OK.

Have I hit yet another restriction that means it is impossible to use a shared JavaScript resource to supply the common "pubsub" functionality? Have you got a solution for this? (I am thinking about effectively duplicating the pubsub code and making them share a common "var" definition to share the "pubsub" event listener state and that is my next thing to try, but if you already have a way to make this work I'd love to know how!)

2 Answers 2

3

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.

8
  • Hi @Salesforce-sas, that's basically what I meant in my last paragraph of the question (forgot to say window scope) - I am assuming that the "interval" in your example is just the data to be communicated, rather than anything to do with the pubsub event approach I referenced which doesn't use this.
    – Phil W
    Oct 21, 2019 at 17:40
  • The other point: there's nothing that guarantees the Message Channel implementation will actually become part of the platform. My solution may have to live quite long term.
    – Phil W
    Oct 21, 2019 at 17:41
  • @PhilW, When you do window.myvar.a = some_data in any component (all components will have access to myvar on window), it will trigger the change handler in the components which implemented the change handler and thus some_data is passed from source component to target component (which implemented change handler). This is not exactly pubsub and so I said work-around. This is a solution based on pure javascript and so it should work as long as standard message service takes to come to GA. Oct 21, 2019 at 17:45
  • 1
    The pubsub code is pure JS too and can be implemented in a similar way. Still, I don't understand why the shared JS static resource approach isn't working.
    – Phil W
    Oct 21, 2019 at 18:07
  • 1
    @PhilW, added the problem with that Oct 22, 2019 at 2:17
2

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.

2
  • This is great! Do you know of a way to pass events to built-in (sibling) components, such as the chatter feed?
    – Shanerk
    Jun 11, 2020 at 13:26
  • No, not aware of a way to do that - the standard Salesforce components expose their own events/event handling that is handled via the standard API property/event flows in the component hierarchy. But this doesn't include things like chatter feed, which isn't a lightning component you can embed in your own component. See the component library docs for available options.
    – Phil W
    Jun 11, 2020 at 15:21

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.