Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ko.utils.domData storage scheme requires explicit cleanup #2141

Merged
merged 3 commits into from
Jul 23, 2017

Conversation

mbest
Copy link
Member

@mbest mbest commented Jul 21, 2017

This problem has been previously brought up in the rejected Issue #1695. Our case idifferent, however: we have no way of ensuring that the explicit ko.cleanNode() is always invoked for the removed element.
Also note that jQuery has switched to storing data on the expando - jquery/jquery#1734. And they had long been advocating for the same data storage solution as Knockout's...

Or use case is as follows:

  1. We rely on custom element support (part of the web component spec) to activate our composite component (see https://developer.mozilla.org/en-US/docs/Web/API/Document/registerElement)
  2. We are using Knockout to manage the composite component's template.
  3. The template will only reference observables stored with the element's properties. No external observables will be used. This means that the custom element should be garbage-collectable when it is removed.
  4. Custom elements are designed to rely on garbage collection for their cleanup, and we cannot require our users to call the explicit cleanup method.

Unfortunately, Knockout's domData maintains references to to all DOM nodes where bindings were applied, so all DOM nodes within the Knockout template remains in memory heap.

Here is a jsFiddle that demonstrates the issue: https://jsfiddle.net/mstarets/4nfsebec/5/. It needs to be run in Chrome (otherwise you will need to include the Custom Element polyfill). After you run the page, click on 'Create Custom Element button, then on 'Remove Custom Element' button. Repeat this sequence 2-3 times. Open Developer Tools, go to the 'Profiles' tab and take a heap snapshot. Note that there are several hundred of HTMLButtonElements that have been leaked. Now uncomment the line calling clearDataForButtons() and repeat the same test. Note that the button elements are no longer leaked.

@mstarets
Copy link
Author

mstarets commented Nov 2, 2016

The fix could require an explicit opt-in (the old behavior would still be the default):

@@ -685,17 +685,30 @@
     var uniqueId = 0;
     var dataStoreKeyExpandoPropertyName = "__ko__" + (new Date).getTime();
     var dataStore = {};
+    var dataStoreInUse = false;

     function getAll(node, createIfNotFound) {
         var dataStoreKey = node[dataStoreKeyExpandoPropertyName];
-        var hasExistingDataStore = dataStoreKey && (dataStoreKey !== "null") && dataStore[dataStoreKey];
-        if (!hasExistingDataStore) {
-            if (!createIfNotFound)
-                return undefined;
-            dataStoreKey = node[dataStoreKeyExpandoPropertyName] = "ko" + uniqueId++;
-            dataStore[dataStoreKey] = {};
+        
+        if (dataStore) {
+          dataStoreInUse = true;
+          var hasExistingDataStore = dataStoreKey && (dataStoreKey !== "null") && dataStore[dataStoreKey];
+          if (!hasExistingDataStore) {
+              if (!createIfNotFound)
+                  return undefined;
+              dataStoreKey = node[dataStoreKeyExpandoPropertyName] = "ko" + uniqueId++;
+              dataStore[dataStoreKey] = {};
+          }
+          return dataStore[dataStoreKey];
         }
-        return dataStore[dataStoreKey];
+        else
+        {
+          var data = dataStoreKey;
+          if (!data && createIfNotFound) {
+            data = node[dataStoreKeyExpandoPropertyName] = {};
+          }
+          return data;
+        }
     }

     return {
@@ -715,7 +728,8 @@
         clear: function (node) {
             var dataStoreKey = node[dataStoreKeyExpandoPropertyName];
             if (dataStoreKey) {
-                delete dataStore[dataStoreKey];
+                if (dataStore)
+                  delete dataStore[dataStoreKey];
                 node[dataStoreKeyExpandoPropertyName] = null;
                 return true; // Exposing "did clean" flag purely so specs can infer whether things have been cleaned up as intended
             }
@@ -724,12 +738,18 @@

         nextKey: function () {
             return (uniqueId++) + dataStoreKeyExpandoPropertyName;
+        },
+        storeOnNode: function() {
+          if (dataStoreInUse)
+            throw "Cannot change data storage scheme after it has been initialized";
+           dataStore = null; 
         }
     };
 })();

 ko.exportSymbol('utils.domData', ko.utils.domData);
 ko.exportSymbol('utils.domData.clear', ko.utils.domData.clear); // Exporting only so specs can clear up after themselves fully
+ko.exportSymbol('utils.domData.storeOnNode', ko.utils.domData.storeOnNode);

 ko.utils.domNodeDisposal = new (function () {
     var domDataKey = ko.utils.domData.nextKey()

@mstarets
Copy link
Author

mstarets commented Nov 2, 2016

Also, would you guys recommend entering this problem as a tko issue (the same code exists there)?

@brianmhunt
Copy link
Member

I'd certainly like this to not be an issue in tko 😀

... but I still don't quite understand the nature of the issue -- will need to stare at it a bit before I can make a meaningful comment.

@brianmhunt
Copy link
Member

... why not use ko.removeNode? Does that solve the issue?

@mstarets
Copy link
Author

mstarets commented Nov 3, 2016

Hey Brian,

ko.removeNode() would certainly clean up DOM data, but it won't solve the issue for us. We are using Knockout as a template engine for the internal template within a custom element/web component. The page author using the component is not supposed to know or care that KO is managing the template (that decision was made by the web component author). They are just seeing a custom element in the DOM (for example, . Web component spec does not call for any explicit cleanup, and the assumption is that the garbage collection will just work after the custom element node is removed from the document.

I agree that current Knockout users are aware that they need to clean up Knockout explicitly. However, I also think that that it would be great if KO supported being used as a template engine within web components, and for that use case supporting cleanup by garbage collection is a must have.

Thanks,
Max

@brianmhunt
Copy link
Member

Got it. Is using MutationObservers to see when elements are removed an option?

@mstarets
Copy link
Author

mstarets commented Nov 3, 2016

Thanks, Brian,

Custom elements are already being notified when they are being attached or detached (the custom component polyfill uses mutation observers to implement that). The problem is that detach is not equivalent to destroy - it could easily be a temporary detach or a move. Allowing garbage collection to take care of things is the best solution, as nobody will be holding references to the custom element if they are not planning to re-insert it into the document.

Max

@mbest
Copy link
Member

mbest commented Nov 3, 2016

The current ko.utils.domData is part of the old-IE compatibility. Perhaps we can include an option to use a different method.

@brianmhunt
Copy link
Member

Maybe using WeakMap @mbest?

@mstarets
Copy link
Author

mstarets commented Nov 4, 2016

+1 to adding an option to switch domData implementation for those who do not care about supporting IE 6 and 7.

Regarding the WeakMap: I am not sure it provides much advantage over storing data in the node's expando. The keys in WeakMap have to be objects, so you would be forced to store an object on the DOM node in any case.
jQuery's implementation may be useful: jquery/jquery#1734. They do not support associating data with comments nodes, but AFAIK this was done a long time ago due to a reason that is not related to their switch away from the global data storage.

@brianmhunt
Copy link
Member

If using a WeakMap, the DOM node would be the key.

The problem with WeakMap is browser support ... IE < 11.

@brianmhunt
Copy link
Member

... another option I toyed with is to use a Symbol property on the node.

brianmhunt added a commit to knockout/tko.utils that referenced this pull request Nov 10, 2016
@brianmhunt
Copy link
Member

So TKO prefers WeakSet if supported, hopefully resolving this issue.

@mstarets
Copy link
Author

Thanks, Brian. The tko change looks good.

@mbest mbest modified the milestone: Not assigned Dec 2, 2016
@mstarets
Copy link
Author

mstarets commented Dec 9, 2016

Hey Brian,

Any chance of backporting the tko fix to the classic Knockout? We have a project that depends on using KO as a template manager with Web Components/Custom Elements, and the only other avenue would be forking.

@brianmhunt
Copy link
Member

It's a simple patch and I've no issue with it being in 3.5 -- @mbest ?

@mbest
Copy link
Member

mbest commented Dec 9, 2016

I'll see what it would involve.

@brianmhunt
Copy link
Member

@mbest This is the patch for tko:

knockout/tko.utils@a1ec331

The API stays the same.

@mstarets
Copy link
Author

@mbest,

We would settle for an opt-in API for this change too if needed.

Thanks,
Max

brianmhunt added a commit to knockout/tko that referenced this pull request May 8, 2017
@mbest
Copy link
Member

mbest commented Jul 21, 2017

@brianmhunt

I'm trying to use your code in Knockout and ran into this issue in IE 11:

Cross-window support Should work in another window.

TypeError: WeakMap.prototype.set: 'key' is not an object

Any ideas?

@mbest
Copy link
Member

mbest commented Jul 21, 2017

It looks like IE and Edge don't support using a node from another window in a WeakMap. Here is a workaround (needed on every access):

var data = node.ownerDocument[dataStoreKeyExpandoPropertyName] ||
    (node.ownerDocument[dataStoreKeyExpandoPropertyName] = new node.ownerDocument.defaultView.WeakMap());

@brianmhunt
Copy link
Member

brianmhunt commented Jul 21, 2017

Thanks @mbest — I've opened an issue in TKO and will mull & merge a PR.

if (window['WeakMap']) {
getDataForNode = function (node, createIfNotFound) {
var ownerDoc = node.ownerDocument,
dataStore = ownerDoc[dataStoreKeyExpandoPropertyName] || (ownerDoc[dataStoreKeyExpandoPropertyName] = new ownerDoc.defaultView['WeakMap']());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good job finding this edge case (pardon the pun)

@mbest
Copy link
Member

mbest commented Jul 21, 2017

Maybe it's just simper to use @mstarets's technique of storing the data on the node itself. The old method would only need to be used in old-IE.

@brianmhunt
Copy link
Member

Yeah an IE test is simpler than this ownerdox logic. Does latest Edge fail too? Is it a known issue?

@mbest
Copy link
Member

mbest commented Jul 21, 2017

Is it a known issue?

There doesn't seem to be a report of it.

https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/?page=1&q=weakmap

@mbest
Copy link
Member

mbest commented Jul 22, 2017

This version doesn't use WeakMap. What do you think?

@brianmhunt
Copy link
Member

I think this is the most practical approach.

I think we should be explicit about what happens with Edge. I wonder if there's a bug report / reporting facility / this would be considered a bug.

if (getAll(node, false) === undefined)
return;
var getDataForNode, clear;
if (!ko.utils.ieVersion) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps this should be

   if (!ko.utils.ieVersion && 'WeakMap' in window) {

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also probably note the bug report here or explain why IE's WeakMap is broken.

@mbest
Copy link
Member

mbest commented Jul 22, 2017

I've reported the WeakMap issue: https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12891461/

@mbest mbest merged commit e57500a into master Jul 23, 2017
@mbest mbest deleted the 2141-domdata-weakmap branch July 23, 2017 19:11
@brianmhunt
Copy link
Member

Noting chakra-core/ChakraCore#2983

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants