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

4

11

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.

Bascom answered 2/2, 2017 at 19:49 Comment(5)
What have you tried so far? You can always use app-storage or iron-localstorage to store component specific data throughout the app. But if it's all on the same, then just binding all properties together would do the trick.Kef
I've been seeing iron-meta and app-storage, and they seem ok but in the case of app-storage there's no way to notify of a prop change among the same element instances and in the case of iron-meta it's a bit weird to me the need to use one instance of that element for each prop (I'm concerned about the performance of this).Bascom
Amendment: With iron-meta is not possible to notify between instances of the same element.Bascom
You could use a standard design pattern like Publisher/Subscriber or Observer/ObservableCystitis
@Cystitis I know, but what I'm asking is how to implement that system using Polymer's web components communications architecture.Bascom
K
4

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).
Kef answered 8/2, 2017 at 14:14 Comment(7)
Thanks for the answer! This is what I tried for first instance. Main problem is that when you do a instance.set(prop, value) you'r triggering the same behaviour again over all the elements (element1 triggers changes over the others changing element2, element2 do the same trying to change element1 with the same value) causing performance issues. On the other hand in your code you'r using the event.type that is hyphenated to set a property that is camelCase so in the case you have a someProp property and you make a change, the behaviour would try to set some-prop on the other elements.Bascom
Yes, the __syncProp fires for all instances, but only once. But this is due to the event listener that fires when each of the instances' property gets updated. Currently I am hooking into the notify property, so there is not a way around this. Maybe if you take a different approach, but currently I cannot think of one. Good point on the camelCase, this is actually an issue in my example. Will try to fix it later today or tomorrow.Kef
Have in mind that some properties could be Arrays or Objects.Bascom
I have updated my answer to cover syncing of Arrays and Objects. It took me a while, since I had a lot of trial and error in order to cover every scenario and invent a locking mechanism for syncing Arrays and Objects. I also fixed the bug with the property names (Dash case vs. Camel case).Kef
@AlbertFM: have you had the time to look at my updated solution?Kef
thanks for the edits! I've been debugging and testing this behaviour. This is what I've found, every time you use instance.set(prop, details.value); you're replacing the instance array pointer with the changed one causing all the element instances share the same array causing errors with the "Polymer splices", happens the same with objects. Also if you could fix a typo in the firsts paragraphs that says <property-name>-change by <property-name>-changed (It doesn't let me edit it). Still debugging and testing it but seems ok. Will accept the answer when I finish the tests.Bascom
Where do you receive the errors regarding the array splices? That line of code (instance.set(prop, details.value);) is intentionally set, so that each of the Object/Array properties from all instances point to the same Object/Array. This means that when one instance updates the Array/Object, the data itself is already changed everywhere. But, since other instances don't detect that change, I manually call notifySplices or notifyPath so that the element is acknowledged of the changes. But those two calls don't actually mutate the Array/Object, but only notifies of what has been changed.Kef
L
3

We have created a component to synchronize data among different instances. Our component is:

<dom-module id="sync-data">
  <template>
    <p>Debug info: {scope:[[scope]], key:[[key]], value:[[value]]}</p>
  </template>
  <script>
    (function () {
      var items = []

      var propagateChangeStatus = {}

      var togglePropagationStatus = function (status) {
        propagateChangeStatus[this.scope + '|' + this.key] = status
      }

      var shouldPropagateChange = function () {
        return propagateChangeStatus[this.scope + '|' + this.key] !== false
      }

      var propagateChange = function (key, scope, value) {
        if (shouldPropagateChange.call(this)) {
          togglePropagationStatus.call(this, false)
          var itemsLength = items.length
          for (var idx = 0; idx < itemsLength; idx += 1) {
            if (items[idx] !== this && items[idx].key === key && items[idx].scope === scope) {
              items[idx].set('value', value)
            }
          }
          togglePropagationStatus.call(this, true)
        }
      }

      Polymer({

        is: 'sync-data',

        properties: {
          key: {
            type: String,
            value: ''
          },
          scope: {
            type: String,
            value: ''
          },
          value: {
            type: String,
            notify: true,
            observer: '_handleValueChanged',
            value: ''
          }
        },

        created: function () {
          items.push(this)
        },

        _handleValueChanged: function (newValue, oldValue) {
          this.typeof = typeof newValue
          propagateChange.call(this, this.key, this.scope, newValue)
        }

      })
    })()
  </script>
</dom-module>

And we use it in a component like this:

<sync-data
  key="email"
  scope="user"
  value="{{email}}"></sync-data>

And in another component like this:

<sync-data
  key="email"
  scope="user"
  value="{{userEmail}}"></sync-data>

In this way we get the native behavior of polymer for events and bindings

Lyophobic answered 14/2, 2017 at 11:26 Comment(0)
J
1

My personal opinion on problems like this is to use flux architecture.

you create a wrapper Element which is distributing all the information to the children. All changes a going via the main component.

<app-wrapper>
<component-x attr="[[someParam]]" />
<component-x attr="[[someParam]]" />
<component-x attr="[[someParam]]" />
</app-wrapper> 

the component-x is firing an change value event on app-wrapper and the app-wrapper is updating someValue, note it's a one-way-binding.

There is a component for this, which is implementing the reduxarchitecture, but its also possible to code your own. It's more or less the observer pattern

Jayjaycee answered 13/2, 2017 at 9:49 Comment(2)
Thanks for you reply! But the question is about communication between same instance of one web component using good practices, redux is used to manage app model shared between different type of elements (also instances) and this data control is not inside the element. In the other hand it is not possible to use a wrapper due that the component-x would be used in other parts of the app.Bascom
@AlbertFM well, so the observer pattern sounds for me like a solution.Jayjaycee
U
-1

Try this for my-app.html. I don't see any reason to not use two-way bindings here.

<dom-module id="my-app">
  <template>
    <my-element my-prop="{{myProp}}"></my-element>
    <my-element my-prop="{{myProp}}"></my-element>
  </template>
  <script>
    Polymer({
      is: 'my-app',
      ready: function() {
        this.myProp = 'test';
      }
    });
  </script>
</dom-module>

Although it's probably a better practice to give myProp a default value by using the properties object rather than the ready callback. Example:

    Polymer({
      is: 'my-app',
      properties: {
        myProp: {
          type: String,
          value: 'test'
      }
    });
Umont answered 14/2, 2017 at 8:13 Comment(1)
Thanks for the answer, but the objective here is to communicate instances of the same element that could be in different DOMs hierarchies (see example in the question)Bascom

© 2022 - 2024 — McMap. All rights reserved.