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

Critical improvements for Map and Set polyfills. #21492

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions Libraries/Core/__tests__/MapAndSetPolyfills-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
benjamn marked this conversation as resolved.
Show resolved Hide resolved
* @format
* @emails oncall+react_native
*/
'use strict';

// Save these methods so that we can restore them afterward.
const {freeze, seal, preventExtensions} = Object;

function setup() {
benjamn marked this conversation as resolved.
Show resolved Hide resolved
jest.setMock('../../vendor/core/_shouldPolyfillES6Collection', () => true);
jest.unmock('_wrapObjectFreezeAndFriends');
require('_wrapObjectFreezeAndFriends');
}

function cleanup() {
Object.assign(Object, {freeze, seal, preventExtensions});
}

describe('Map polyfill', () => {
setup();

const Map = require('Map');

it('is not native', () => {
const getCode = Function.prototype.toString.call(Map.prototype.get);
expect(getCode).not.toContain('[native code]');
expect(getCode).toContain('getIndex');
});

it('should tolerate non-extensible object keys', () => {
const map = new Map();
const key = Object.create(null);
Object.freeze(key);
map.set(key, key);
expect(map.size).toBe(1);
expect(map.has(key)).toBe(true);
map.delete(key);
expect(map.size).toBe(0);
expect(map.has(key)).toBe(false);
});

it('should not get confused by prototypal inheritance', () => {
const map = new Map();
const proto = Object.create(null);
const base = Object.create(proto);
map.set(proto, proto);
expect(map.size).toBe(1);
expect(map.has(proto)).toBe(true);
expect(map.has(base)).toBe(false);
map.set(base, base);
expect(map.size).toBe(2);
expect(map.get(proto)).toBe(proto);
expect(map.get(base)).toBe(base);
});

afterAll(cleanup);
});

describe('Set polyfill', () => {
setup();

const Set = require('Set');

it('is not native', () => {
const addCode = Function.prototype.toString.call(Set.prototype.add);
expect(addCode).not.toContain('[native code]');
});

it('should tolerate non-extensible object elements', () => {
const set = new Set();
const elem = Object.create(null);
Object.freeze(elem);
set.add(elem);
expect(set.size).toBe(1);
expect(set.has(elem)).toBe(true);
set.add(elem);
expect(set.size).toBe(1);
set.delete(elem);
expect(set.size).toBe(0);
expect(set.has(elem)).toBe(false);
});

it('should not get confused by prototypal inheritance', () => {
const set = new Set();
const proto = Object.create(null);
const base = Object.create(proto);
set.add(proto);
expect(set.size).toBe(1);
expect(set.has(proto)).toBe(true);
expect(set.has(base)).toBe(false);
set.add(base);
expect(set.size).toBe(2);
expect(set.has(proto)).toBe(true);
expect(set.has(base)).toBe(true);
});

afterAll(cleanup);
});
2 changes: 2 additions & 0 deletions Libraries/Core/polyfillES6Collections.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ const {polyfillGlobal} = require('PolyfillFunctions');
*/
const _shouldPolyfillCollection = require('_shouldPolyfillES6Collection');
if (_shouldPolyfillCollection('Map')) {
require('_wrapObjectFreezeAndFriends');

Choose a reason for hiding this comment

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

Importing from an untyped module makes it any and is not safe! Did you mean to add // @flow to the top of _wrapObjectFreezeAndFriends? (untyped-import)

polyfillGlobal('Map', () => require('Map'));
}
if (_shouldPolyfillCollection('Set')) {
require('_wrapObjectFreezeAndFriends');

Choose a reason for hiding this comment

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

Importing from an untyped module makes it any and is not safe! Did you mean to add // @flow to the top of _wrapObjectFreezeAndFriends? (untyped-import)

polyfillGlobal('Set', () => require('Set'));
}
71 changes: 43 additions & 28 deletions Libraries/vendor/core/Map.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ module.exports = (function(global, undefined) {
return global.Map;
}

// In case this module has not already been evaluated, import it now.
require('./_wrapObjectFreezeAndFriends');

const hasOwn = Object.prototype.hasOwnProperty;

/**
* == ES6 Map Collection ==
*
Expand All @@ -38,15 +43,17 @@ module.exports = (function(global, undefined) {
*
* https://people.mozilla.org/~jorendorff/es6-draft.html#sec-map-objects
*
* There only two -- rather small -- diviations from the spec:
Copy link
Contributor

Choose a reason for hiding this comment

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

lol

* There only two -- rather small -- deviations from the spec:
*
* 1. The use of frozen objects as keys.
* We decided not to allow and simply throw an error. The reason being is
* we store a "hash" on the object for fast access to it's place in the
* internal map entries.
* If this turns out to be a popular use case it's possible to implement by
* overiding `Object.freeze` to store a "hash" property on the object
* for later use with the map.
* 1. The use of untagged frozen objects as keys.
* We decided not to allow and simply throw an error, because this
* implementation of Map works by tagging objects used as Map keys
* with a secret hash property for fast access to the object's place
* in the internal _mapData array. However, to limit the impact of
* this spec deviation, Libraries/Core/InitializeCore.js also wraps
* Object.freeze, Object.seal, and Object.preventExtensions so that
* they tag objects before making them non-extensible, by inserting
* each object into a Map and then immediately removing it.
*
* 2. The `size` property on a map object is a regular property and not a
* computed property on the prototype as described by the spec.
Expand Down Expand Up @@ -445,7 +452,7 @@ module.exports = (function(global, undefined) {
// If the `SECRET_SIZE_PROP` property is already defined then we're not
// in the first call to `initMap` (e.g. coming from `map.clear()`) so
// all we need to do is reset the size without defining the properties.
if (map.hasOwnProperty(SECRET_SIZE_PROP)) {
if (hasOwn.call(map, SECRET_SIZE_PROP)) {
map[SECRET_SIZE_PROP] = 0;
} else {
Object.defineProperty(map, SECRET_SIZE_PROP, {
Expand Down Expand Up @@ -524,51 +531,59 @@ module.exports = (function(global, undefined) {
const hashProperty = '__MAP_POLYFILL_INTERNAL_HASH__';
let hashCounter = 0;

const nonExtensibleObjects = [];
const nonExtensibleHashes = [];

/**
* Get the "hash" associated with an object.
*
* @param {object|array|function|regexp} o
* @return {number}
*/
return function getHash(o) {
// eslint-disable-line no-shadow
if (o[hashProperty]) {
return o[hashProperty];
} else if (
!isES5 &&
o.propertyIsEnumerable &&
o.propertyIsEnumerable[hashProperty]
) {
return o.propertyIsEnumerable[hashProperty];
} else if (!isES5 && o[hashProperty]) {
if (hasOwn.call(o, hashProperty)) {
return o[hashProperty];
}

if (!isES5) {
if (hasOwn.call(o, "propertyIsEnumerable") &&
hasOwn.call(o.propertyIsEnumerable, hashProperty)) {
return o.propertyIsEnumerable[hashProperty];
}
}

if (isExtensible(o)) {
hashCounter += 1;
if (isES5) {
Object.defineProperty(o, hashProperty, {
enumerable: false,
writable: false,
configurable: false,
value: hashCounter,
value: ++hashCounter,
});
} else if (o.propertyIsEnumerable) {
return hashCounter;
}

if (o.propertyIsEnumerable) {
// Since we can't define a non-enumerable property on the object
// we'll hijack one of the less-used non-enumerable properties to
// save our hash on it. Additionally, since this is a function it
// will not show up in `JSON.stringify` which is what we want.
o.propertyIsEnumerable = function() {
return propIsEnumerable.apply(this, arguments);
};
o.propertyIsEnumerable[hashProperty] = hashCounter;
} else {
throw new Error('Unable to set a non-enumerable property on object.');
return o.propertyIsEnumerable[hashProperty] = ++hashCounter;
}
return hashCounter;
} else {
throw new Error('Non-extensible objects are not allowed as keys.');
}

// If the object is not extensible, fall back to storing it in an
// array and using Array.prototype.indexOf to find it.
let index = nonExtensibleObjects.indexOf(o);
if (index < 0) {
index = nonExtensibleObjects.length;
nonExtensibleObjects[index] = o;
nonExtensibleHashes[index] = ++hashCounter;
}
return nonExtensibleHashes[index];
};
})();

Expand Down
37 changes: 37 additions & 0 deletions Libraries/vendor/core/_wrapObjectFreezeAndFriends.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @author Ben Newman (@benjamn) <ben@benjamn.com>
benjamn marked this conversation as resolved.
Show resolved Hide resolved
* @flow
* @format
*/

'use strict';

let testMap; // Initialized lazily.
function getTestMap() {
return testMap || (testMap = new (require('./Map'))());
}

// Wrap Object.{freeze,seal,preventExtensions} so each function adds its
// argument to a Map first, which gives our ./Map.js polyfill a chance to
// tag the object before it becomes non-extensible.
["freeze", "seal", "preventExtensions"].forEach(name => {
const method = Object[name];
if (typeof method === "function") {
(Object: any)[name] = function (obj) {
try {
// If .set succeeds, also call .delete to avoid leaking memory.
getTestMap().set(obj, obj).delete(obj);
} finally {
// If .set fails, the exception will be silently swallowed
// by this return-from-finally statement, and the method will
// behave exactly as it did before it was wrapped.
Copy link

Choose a reason for hiding this comment

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

... with the potential of leaking memory, unreported, if the error happens in the .delete() portion of things.

Hypothetically; if something wants to call freeze or seal on an object, how likely is it that it actually wouldn't want the __MAP_POLYFILL_INTERNAL_HASH__ expando applied to the object itself, in the first place? Wouldn't we be better and safer off letting it use the (new-in-this-PR) fallback method instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Wouldn't we be better and safer off letting it use the (new-in-this-PR) fallback method instead?

No, because the fallback method has linear lookup time, compared to the tagging approach, which has constant lookup time. That's a nice advantage of this polyfill over other (better tested, more widely used) polyfills that settle for linear time in order to avoid tagging objects. If we don't care about the performance benefit of tagging, then we should really replace this whole polyfill with one that wasn't invented here.

Copy link
Contributor Author

@benjamn benjamn Oct 11, 2018

Choose a reason for hiding this comment

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

I'm confident that .delete(obj) will always succeed if .set(obj, obj) succeeds in putting obj into the map. If that invariant does not hold, there's a bug, and we will fix it. And if .set(obj, obj) fails to put obj into the map, then there won't be any memory leak, whether or not .delete(obj) is called/succeeds.

return method.call(Object, obj);
}
};
}
});