Search code examples
polymerpolymer-1.0web-componentpolymer-2.x

Best way to communicate between instances of the same web component with Polymer?


I'm trying to sync some of my web component properties between instances of the same element so if one of this properties changes then the same property gets updated in all the instances with the corresponding binding and events.

Note: I want to use the Polymer Data System Concepts for the communications between instances.

Example

my-element.html

<dom-module id="my-element">
  <script>
    Polymer({
      is: 'my-element',

      properties: {
        myProp: {
          type: String,
          notify: true
      }
    });
  </script>
</dom-module>

my-other-element.html

<dom-module id="my-other-element">
  <template>
    <my-element my-prop="{{otherProp}}"></my-element>
  </template>
  <script>
    Polymer({
      is: 'my-other-element',
      properties: {
        otherProp: {
          type: String,
          notify: true,
          readOnly: true
        }
      }
    })
  </script>
</dom-module>

my-app.html

<dom-module id="my-app">
  <template>
    <my-element id="element"></my-element>
    <my-other-element id="otherElement"
      on-other-prop-changed="onPropChanged"
    ></my-other-element>
  </template>
  <script>
    Polymer({
      is: 'my-app',

      attached: function () {
        // should set 'myProp' to 'test' and trigger
        // the event 'my-prop-changed' in all my-element instances
        this.$.element.myProp = 'test'
      },

      onPropChanged: function (ev, detail) {
        console.log(detail.value); // should print 'test'
        console.log(this.$.element.myProp); // should print 'test'
        console.log(this.$.otherElement.otherProp); // should print 'test'
      }
    });
  </script>
</dom-module>

PD: Would be good to use standard like patterns and good practices.


Solution

  • tl;dr

    I have created a custom behaviour that syncs all elements' properties that have notify: true. Working prototype: JSBin.

    Currently, this prototype does not distinguish between different kinds of elements, meaning that it can only sync instances of the same custom element - but this can be changed without much effort.

    You could also tailor the behaviour so that is syncs only the desired properties and not just all with notify: true. However, if you take this path, be advised that all the properties you want to sync must have notify: true, since the behaviour listens to the <property-name>-changed event, which is fired only if the property has notify: true.

    The details

    Let's start with the custom SyncBehavior behaviour:

    (function() {
        var SyncBehaviorInstances = [];
        var SyncBehaviorLock = false;
    
        SyncBehavior = {
            attached: function() {
                // Add instance
                SyncBehaviorInstances.push(this);
    
                // Add listeners
                for(var property in this.properties) {
                    if('notify' in this.properties[property] && this.properties[property].notify) {
                        // Watch all properties with notify = true
                        var eventHanler = this._eventHandlerForPropertyType(this.properties[property].type.name);
                        this.listen(this, Polymer.CaseMap.camelToDashCase(property) + '-changed', eventHanler);
                    }
                }
            },
    
            detached: function() {
                // Remove instance
                var index = SyncBehaviorInstances.indexOf(this);
                if(index >= 0) {
                    SyncBehaviorInstances.splice(index, 1);
                }
    
                // Remove listeners
                for(var property in this.properties) {
                    if('notify' in this.properties[property] && this.properties[property].notify) {
                        // Watch all properties with notify = true
                        var eventHanler = this._eventHandlerForPropertyType(this.properties[property].type.name);
                        this.unlisten(this, Polymer.CaseMap.camelToDashCase(property) + '-changed', eventHanler);
                    }
                }
            },
    
            _eventHandlerForPropertyType: function(propertyType) {
                switch(propertyType) {
                    case 'Array':
                        return '__syncArray';
                    case 'Object':
                        return '__syncObject';
                    default:
                        return '__syncPrimitive';
                }
            },
    
            __syncArray: function(event, details) {
                if(SyncBehaviorLock) {
                    return; // Prevent cycles
                }
    
                SyncBehaviorLock = true; // Lock
    
                var target = event.target;
                var prop = Polymer.CaseMap.dashToCamelCase(event.type.substr(0, event.type.length - 8));
    
                if(details.path === undefined) {
                    // New array -> assign by reference
                    SyncBehaviorInstances.forEach(function(instance) {
                        if(instance !== target) {
                            instance.set(prop, details.value);
                        }
                    });
                } else if(details.path.endsWith('.splices')) {
                    // Array mutation -> apply notifySplices
                    var splices = details.value.indexSplices;
    
                    // for all other instances: assign reference if not the same, otherwise call 'notifySplices'
                    SyncBehaviorInstances.forEach(function(instance) {
                        if(instance !== target) {
                            var instanceReference = instance.get(prop);
                            var targetReference = target.get(prop);
    
                            if(instanceReference !== targetReference) {
                                instance.set(prop, targetReference);
                            } else {
                                instance.notifySplices(prop, splices);
                            }
                        }
                    });
                }
    
                SyncBehaviorLock = false; // Unlock
            },
    
            __syncObject: function(event, details) {
                var target = event.target;
                var prop = Polymer.CaseMap.dashToCamelCase(event.type.substr(0, event.type.length - 8));
    
                if(details.path === undefined) {
                    // New object -> assign by reference
                    SyncBehaviorInstances.forEach(function(instance) {
                        if(instance !== target) {
                            instance.set(prop, details.value);
                        }
                    });
                } else {
                    // Property change -> assign by reference if not the same, otherwise call 'notifyPath'
                    SyncBehaviorInstances.forEach(function(instance) {
                        if(instance !== target) {
                            var instanceReference = instance.get(prop);
                            var targetReference = target.get(prop);
    
                            if(instanceReference !== targetReference) {
                                instance.set(prop, targetReference);
                            } else {
                                instance.notifyPath(details.path, details.value);
                            }
                        }
                    });
                }
            },
    
            __syncPrimitive: function(event, details) {
                var target = event.target;
                var value = details.value;
                var prop = Polymer.CaseMap.dashToCamelCase(event.type.substr(0, event.type.length - 8));
    
                SyncBehaviorInstances.forEach(function(instance) {
                    if(instance !== target) {
                        instance.set(prop, value);
                    }
                });
            },
        };
    })();
    

    Notice that I have used the IIFE pattern to hide the variable that holds all instances of the custom element my-element. This is essential, so don't change it.

    As you can see, the behaviour consists of six functions, namely:

    1. attached, which adds the current instance to the list of instances and registers listeners for all properties with notify: true.
    2. detached, which removes the current instance from the list of instances and removes listeners for all properties with notify: true.
    3. _eventHandlerForPropertyType, which returns the name of one of the functions 4-6, depending on the property type.
    4. __syncArray, which syncs the Array type properties between the instances. Notice that I ignore the current target and implement a simple locking mechanism in order to avoid cycles. The method handles two scenarios: assigning a new Array, and mutating an existing Array.
    5. __syncObject, which syncs the Object type properties between the instances. Notice that I ignore the current target and implement a simple locking mechanism in order to avoid cycles. The method handles two scenarios: assigning a new Object, and changing a property of an existing Object.
    6. __syncPrimitive, which syncs the primitive values of properties between the instances. Notice that I ignore the current target in order to avoid cycles.

    In order to test-drive my new behaviour, I have created a sample custom element:

    <dom-module id="my-element">
        <template>
            <style>
                :host {
                    display: block;
                }
            </style>
    
            <h2>Hello [[id]]</h2>
            <ul>
                <li>propString: [[propString]]</li>
                <li>
                    propArray:
                    <ol>
                        <template is="dom-repeat" items="[[propArray]]">
                            <li>[[item]]</li>
                        </template>
                    </ol>
                </li>
                <li>
                    propObject:
                    <ul>
                        <li>name: [[propObject.name]]</li>
                        <li>surname: [[propObject.surname]]</li>
                    </ul>
                </li>
            </ul>
        </template>
    
        <script>
            Polymer({
                is: 'my-element',
                behaviors: [
                    SyncBehavior,
                ],
                properties: {
                    id: {
                        type: String,
                    },
                    propString: {
                        type: String,
                        notify: true,
                        value: 'default value',
                    },
                    propArray: {
                        type: Array,
                        notify: true,
                        value: function() {
                            return ['a', 'b', 'c'];
                        },
                    },
                    propObject: {
                        type: Object,
                        notify: true,
                        value: function() {
                            return {'name': 'John', 'surname': 'Doe'};
                        },
                    },
                },
                pushToArray: function(item) {
                    this.push('propArray', item);
                },
                pushToNewArray: function(item) {
                    this.set('propArray', [item]);
                },
                popFromArray: function() {
                    this.pop('propArray');
                },
                setObjectName: function(name) {
                    this.set('propObject.name', name);
                },
                setNewObjectName: function(name) {
                    this.set('propObject', {'name': name, 'surname': 'unknown'});
                },
            });
        </script>
    </dom-module>
    

    It has one String property, one Array property, and one Object property; all with notify: true. The custom element also implements the SyncBehavior behaviour.

    To combine all of the above in a working prototype, you simply do this:

    <template is="dom-bind">
        <h4>Primitive type</h4>
        propString: <input type="text" value="{{propString::input}}" />
    
        <h4>Array type</h4>
        Push to propArray: <input type="text" id="propArrayItem" /> <button onclick="_propArrayItem()">Push</button> <button onclick="_propNewArrayItem()">Push to NEW array</button> <button onclick="_propPopArrayItem()">Delete last element</button>
    
        <h4>Object type</h4>
        Set 'name' of propObject: <input type="text" id="propObjectName" /> <button onclick="_propObjectName()">Set</button> <button onclick="_propNewObjectName()">Set to NEW object</button> <br />
    
        <script>
            function _propArrayItem() {
                one.pushToArray(propArrayItem.value);
            }
    
            function _propNewArrayItem() {
                one.pushToNewArray(propArrayItem.value);
            }
    
            function _propPopArrayItem() {
                one.popFromArray();
            }
    
            function _propObjectName() {
                one.setObjectName(propObjectName.value);
            }
    
            function _propNewObjectName() {
                one.setNewObjectName(propObjectName.value);
            }
        </script>
    
        <my-element id="one" prop-string="{{propString}}"></my-element>
        <my-element id="two"></my-element>
        <my-element id="three"></my-element>
        <my-element id="four"></my-element>
    </template>
    

    In this prototype, I have created four instances of my-element. One has propString bound to an input, while the others don't have any bindings at all. I have created a simple form, that covers every scenario I could think of:

    • Changing a primitive value.
    • Pushing an item to an array.
    • Creating a new array (with one item).
    • Deleting an item from the array.
    • Setting object property.
    • Creating a new object.

    EDIT

    I have updated my post and the prototype in order to address the following issues:

    • Syncing of non-primitive values, namely Array and Object.
    • Properly converting property names from Dash case to Camel case (and vice-versa).