From 323414bce8194bb6a62f20a3e6960dac91d38ae0 Mon Sep 17 00:00:00 2001 From: Dima Voytenko Date: Tue, 5 Jan 2021 15:49:48 -0800 Subject: [PATCH] Support shadow DOM and same-origin iframes --- dist/ResizeObserver.js | 1423 +++++++++-------- package-lock.json | 43 +- package.json | 2 +- src/GlobalResizeObserverController.js | 83 + src/ResizeObservation.js | 14 +- src/ResizeObserver.js | 4 +- src/ResizeObserverController.js | 90 +- src/ResizeObserverSPI.js | 137 +- src/shims/getRootNode.js | 20 + tests/ResizeObserver.spec.js | 2019 +++++++++++++------------ 10 files changed, 2241 insertions(+), 1594 deletions(-) create mode 100644 src/GlobalResizeObserverController.js create mode 100644 src/shims/getRootNode.js diff --git a/dist/ResizeObserver.js b/dist/ResizeObserver.js index d72927e..10f2761 100644 --- a/dist/ResizeObserver.js +++ b/dist/ResizeObserver.js @@ -111,9 +111,24 @@ })(); /** - * Detects whether window and document objects are available in current environment. + * Defines non-writable/enumerable properties of the provided target object. + * + * @param {Object} target - Object for which to define properties. + * @param {Object} props - Properties to be defined. + * @returns {Object} Target object. */ - var isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined' && window.document === document; + var defineConfigurable = (function (target, props) { + for (var _i = 0, _a = Object.keys(props); _i < _a.length; _i++) { + var key = _a[_i]; + Object.defineProperty(target, key, { + value: props[key], + enumerable: false, + writable: false, + configurable: true + }); + } + return target; + }); // Returns global object of a current environment. var global$1 = (function () { @@ -131,756 +146,968 @@ })(); /** - * A shim for the requestAnimationFrame which falls back to the setTimeout if - * first one is not supported. + * Returns the global object associated with provided element. * - * @returns {number} Requests' identifier. + * @param {Object} target + * @returns {Object} */ - var requestAnimationFrame$1 = (function () { - if (typeof requestAnimationFrame === 'function') { - // It's required to use a bounded function because IE sometimes throws - // an "Invalid calling object" error if rAF is invoked without the global - // object on the left hand side. - return requestAnimationFrame.bind(global$1); - } - return function (callback) { return setTimeout(function () { return callback(Date.now()); }, 1000 / 60); }; - })(); + var getWindowOf = (function (target) { + // Assume that the element is an instance of Node, which means that it + // has the "ownerDocument" property from which we can retrieve a + // corresponding global object. + var ownerGlobal = target && target.ownerDocument && target.ownerDocument.defaultView; + // Return the local global object if it's not possible extract one from + // provided element. + return ownerGlobal || global$1; + }); - // Defines minimum timeout before adding a trailing call. - var trailingTimeout = 2; /** - * Creates a wrapper function which ensures that provided callback will be - * invoked only once during the specified delay period. + * Detects whether window and document objects are available in current environment. + */ + var isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined' && window.document === document; + + // Placeholder of an empty content rectangle. + var emptyRect = createRectInit(0, 0, 0, 0); + /** + * Converts provided string to a number. * - * @param {Function} callback - Function to be invoked after the delay period. - * @param {number} delay - Delay after which to invoke callback. - * @returns {Function} + * @param {number|string} value + * @returns {number} */ - function throttle (callback, delay) { - var leadingCall = false, trailingCall = false, lastCallTime = 0; - /** - * Invokes the original callback function and schedules new invocation if - * the "proxy" was called during current request. - * - * @returns {void} - */ - function resolvePending() { - if (leadingCall) { - leadingCall = false; - callback(); + function toFloat(value) { + return parseFloat(value) || 0; + } + /** + * Extracts borders size from provided styles. + * + * @param {CSSStyleDeclaration} styles + * @param {...string} positions - Borders positions (top, right, ...) + * @returns {number} + */ + function getBordersSize(styles) { + var positions = []; + for (var _i = 1; _i < arguments.length; _i++) { + positions[_i - 1] = arguments[_i]; + } + return positions.reduce(function (size, position) { + var value = styles['border-' + position + '-width']; + return size + toFloat(value); + }, 0); + } + /** + * Extracts paddings sizes from provided styles. + * + * @param {CSSStyleDeclaration} styles + * @returns {Object} Paddings box. + */ + function getPaddings(styles) { + var positions = ['top', 'right', 'bottom', 'left']; + var paddings = {}; + for (var _i = 0, positions_1 = positions; _i < positions_1.length; _i++) { + var position = positions_1[_i]; + var value = styles['padding-' + position]; + paddings[position] = toFloat(value); + } + return paddings; + } + /** + * Calculates content rectangle of provided SVG element. + * + * @param {SVGGraphicsElement} target - Element content rectangle of which needs + * to be calculated. + * @returns {DOMRectInit} + */ + function getSVGContentRect(target) { + var bbox = target.getBBox(); + return createRectInit(0, 0, bbox.width, bbox.height); + } + /** + * Calculates content rectangle of provided HTMLElement. + * + * @param {HTMLElement} target - Element for which to calculate the content rectangle. + * @returns {DOMRectInit} + */ + function getHTMLElementContentRect(target) { + // Client width & height properties can't be + // used exclusively as they provide rounded values. + var clientWidth = target.clientWidth, clientHeight = target.clientHeight; + // By this condition we can catch all non-replaced inline, hidden and + // detached elements. Though elements with width & height properties less + // than 0.5 will be discarded as well. + // + // Without it we would need to implement separate methods for each of + // those cases and it's not possible to perform a precise and performance + // effective test for hidden elements. E.g. even jQuery's ':visible' filter + // gives wrong results for elements with width & height less than 0.5. + if (!clientWidth && !clientHeight) { + return emptyRect; + } + var styles = getWindowOf(target).getComputedStyle(target); + var paddings = getPaddings(styles); + var horizPad = paddings.left + paddings.right; + var vertPad = paddings.top + paddings.bottom; + // Computed styles of width & height are being used because they are the + // only dimensions available to JS that contain non-rounded values. It could + // be possible to utilize the getBoundingClientRect if only it's data wasn't + // affected by CSS transformations let alone paddings, borders and scroll bars. + var width = toFloat(styles.width), height = toFloat(styles.height); + // Width & height include paddings and borders when the 'border-box' box + // model is applied (except for IE). + if (styles.boxSizing === 'border-box') { + // Following conditions are required to handle Internet Explorer which + // doesn't include paddings and borders to computed CSS dimensions. + // + // We can say that if CSS dimensions + paddings are equal to the "client" + // properties then it's either IE, and thus we don't need to subtract + // anything, or an element merely doesn't have paddings/borders styles. + if (Math.round(width + horizPad) !== clientWidth) { + width -= getBordersSize(styles, 'left', 'right') + horizPad; } - if (trailingCall) { - proxy(); + if (Math.round(height + vertPad) !== clientHeight) { + height -= getBordersSize(styles, 'top', 'bottom') + vertPad; } } - /** - * Callback invoked after the specified delay. It will further postpone - * invocation of the original function delegating it to the - * requestAnimationFrame. - * - * @returns {void} - */ - function timeoutCallback() { - requestAnimationFrame$1(resolvePending); - } - /** - * Schedules invocation of the original function. - * - * @returns {void} - */ - function proxy() { - var timeStamp = Date.now(); - if (leadingCall) { - // Reject immediately following calls. - if (timeStamp - lastCallTime < trailingTimeout) { - return; - } - // Schedule new call to be in invoked when the pending one is resolved. - // This is important for "transitions" which never actually start - // immediately so there is a chance that we might miss one if change - // happens amids the pending invocation. - trailingCall = true; + // Following steps can't be applied to the document's root element as its + // client[Width/Height] properties represent viewport area of the window. + // Besides, it's as well not necessary as the itself neither has + // rendered scroll bars nor it can be clipped. + if (!isDocumentElement(target)) { + // In some browsers (only in Firefox, actually) CSS width & height + // include scroll bars size which can be removed at this step as scroll + // bars are the only difference between rounded dimensions + paddings + // and "client" properties, though that is not always true in Chrome. + var vertScrollbar = Math.round(width + horizPad) - clientWidth; + var horizScrollbar = Math.round(height + vertPad) - clientHeight; + // Chrome has a rather weird rounding of "client" properties. + // E.g. for an element with content width of 314.2px it sometimes gives + // the client width of 315px and for the width of 314.7px it may give + // 314px. And it doesn't happen all the time. So just ignore this delta + // as a non-relevant. + if (Math.abs(vertScrollbar) !== 1) { + width -= vertScrollbar; } - else { - leadingCall = true; - trailingCall = false; - setTimeout(timeoutCallback, delay); + if (Math.abs(horizScrollbar) !== 1) { + height -= horizScrollbar; } - lastCallTime = timeStamp; } - return proxy; + return createRectInit(paddings.left, paddings.top, width, height); } - - // Minimum delay before invoking the update of observers. - var REFRESH_DELAY = 20; - // A list of substrings of CSS properties used to find transition events that - // might affect dimensions of observed elements. - var transitionKeys = ['top', 'right', 'bottom', 'left', 'width', 'height', 'size', 'weight']; - // Check if MutationObserver is available. - var mutationObserverSupported = typeof MutationObserver !== 'undefined'; /** - * Singleton controller class which handles updates of ResizeObserver instances. + * Checks whether provided element is an instance of the SVGGraphicsElement. + * + * @param {Element} target - Element to be checked. + * @returns {boolean} */ - var ResizeObserverController = /** @class */ (function () { - /** - * Creates a new instance of ResizeObserverController. - * - * @private - */ - function ResizeObserverController() { - /** - * Indicates whether DOM listeners have been added. - * - * @private {boolean} - */ - this.connected_ = false; - /** - * Tells that controller has subscribed for Mutation Events. - * - * @private {boolean} - */ - this.mutationEventsAdded_ = false; - /** - * Keeps reference to the instance of MutationObserver. - * - * @private {MutationObserver} - */ - this.mutationsObserver_ = null; - /** - * A list of connected observers. + var isSVGGraphicsElement = (function () { + // Some browsers, namely IE and Edge, don't have the SVGGraphicsElement + // interface. + if (typeof SVGGraphicsElement !== 'undefined') { + return function (target) { return target instanceof getWindowOf(target).SVGGraphicsElement; }; + } + // If it's so, then check that element is at least an instance of the + // SVGElement and that it has the "getBBox" method. + // eslint-disable-next-line no-extra-parens + return function (target) { return (target instanceof getWindowOf(target).SVGElement && + typeof target.getBBox === 'function'); }; + })(); + /** + * Checks whether provided element is a document element (). + * + * @param {Element} target - Element to be checked. + * @returns {boolean} + */ + function isDocumentElement(target) { + return target === getWindowOf(target).document.documentElement; + } + /** + * Calculates an appropriate content rectangle for provided html or svg element. + * + * @param {Element} target - Element content rectangle of which needs to be calculated. + * @returns {DOMRectInit} + */ + function getContentRect(target) { + if (!isBrowser) { + return emptyRect; + } + if (isSVGGraphicsElement(target)) { + return getSVGContentRect(target); + } + return getHTMLElementContentRect(target); + } + /** + * Creates rectangle with an interface of the DOMRectReadOnly. + * Spec: https://drafts.fxtf.org/geometry/#domrectreadonly + * + * @param {DOMRectInit} rectInit - Object with rectangle's x/y coordinates and dimensions. + * @returns {DOMRectReadOnly} + */ + function createReadOnlyRect(_a) { + var x = _a.x, y = _a.y, width = _a.width, height = _a.height; + // If DOMRectReadOnly is available use it as a prototype for the rectangle. + var Constr = typeof DOMRectReadOnly !== 'undefined' ? DOMRectReadOnly : Object; + var rect = Object.create(Constr.prototype); + // Rectangle's properties are not writable and non-enumerable. + defineConfigurable(rect, { + x: x, y: y, width: width, height: height, + top: y, + right: x + width, + bottom: height + y, + left: x + }); + return rect; + } + /** + * Creates DOMRectInit object based on the provided dimensions and the x/y coordinates. + * Spec: https://drafts.fxtf.org/geometry/#dictdef-domrectinit + * + * @param {number} x - X coordinate. + * @param {number} y - Y coordinate. + * @param {number} width - Rectangle's width. + * @param {number} height - Rectangle's height. + * @returns {DOMRectInit} + */ + function createRectInit(x, y, width, height) { + return { x: x, y: y, width: width, height: height }; + } + + /** + * Class that is responsible for computations of the content rectangle of + * provided DOM element and for keeping track of it's changes. + */ + var ResizeObservation = /** @class */ (function () { + /** + * Creates an instance of ResizeObservation. + * + * @param {Element} target - Element to be observed. + * @param {Node} rootNode - The root node of the element at the time + * of subscription. + */ + function ResizeObservation(target, rootNode) { + /** + * Broadcasted width of content rectangle. * - * @private {Array} + * @type {number} */ - this.observers_ = []; - this.onTransitionEnd_ = this.onTransitionEnd_.bind(this); - this.refresh = throttle(this.refresh.bind(this), REFRESH_DELAY); + this.broadcastWidth = 0; + /** + * Broadcasted height of content rectangle. + * + * @type {number} + */ + this.broadcastHeight = 0; + /** + * Reference to the last observed content rectangle. + * + * @private {DOMRectInit} + */ + this.contentRect_ = createRectInit(0, 0, 0, 0); + this.target = target; + this.rootNode = rootNode; } /** - * Adds observer to observers list. + * Updates content rectangle and tells whether it's width or height properties + * have changed since the last broadcast. * - * @param {ResizeObserverSPI} observer - Observer to be added. - * @returns {void} + * @returns {boolean} */ - ResizeObserverController.prototype.addObserver = function (observer) { - if (!~this.observers_.indexOf(observer)) { - this.observers_.push(observer); - } - // Add listeners if they haven't been added yet. - if (!this.connected_) { - this.connect_(); - } + ResizeObservation.prototype.isActive = function () { + var rect = getContentRect(this.target); + this.contentRect_ = rect; + return (rect.width !== this.broadcastWidth || + rect.height !== this.broadcastHeight); }; /** - * Removes observer from observers list. + * Updates 'broadcastWidth' and 'broadcastHeight' properties with a data + * from the corresponding properties of the last observed content rectangle. * - * @param {ResizeObserverSPI} observer - Observer to be removed. - * @returns {void} + * @returns {DOMRectInit} Last observed content rectangle. */ - ResizeObserverController.prototype.removeObserver = function (observer) { - var observers = this.observers_; - var index = observers.indexOf(observer); - // Remove observer if it's present in registry. - if (~index) { - observers.splice(index, 1); - } - // Remove listeners if controller has no connected observers. - if (!observers.length && this.connected_) { - this.disconnect_(); - } + ResizeObservation.prototype.broadcastRect = function () { + var rect = this.contentRect_; + this.broadcastWidth = rect.width; + this.broadcastHeight = rect.height; + return rect; }; + return ResizeObservation; + }()); + + var ResizeObserverEntry = /** @class */ (function () { /** - * Invokes the update of observers. It will continue running updates insofar - * it detects changes. + * Creates an instance of ResizeObserverEntry. * - * @returns {void} + * @param {Element} target - Element that is being observed. + * @param {DOMRectInit} rectInit - Data of the element's content rectangle. */ - ResizeObserverController.prototype.refresh = function () { - var changesDetected = this.updateObservers_(); - // Continue running updates if changes have been detected as there might - // be future ones caused by CSS transitions. - if (changesDetected) { - this.refresh(); - } - }; + function ResizeObserverEntry(target, rectInit) { + var contentRect = createReadOnlyRect(rectInit); + // According to the specification following properties are not writable + // and are also not enumerable in the native implementation. + // + // Property accessors are not being used as they'd require to define a + // private WeakMap storage which may cause memory leaks in browsers that + // don't support this type of collections. + defineConfigurable(this, { target: target, contentRect: contentRect }); + } + return ResizeObserverEntry; + }()); + + /** + * A shim for the `Node.getRootNode()` API. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/Node/getRootNode for + * more info. + * + * @param {Node} node + * @returns {Node} + */ + function getRootNode(node) { + if (typeof node.getRootNode === 'function') { + return node.getRootNode(); + } + var n; + // eslint-disable-next-line no-empty + for (n = node; n.parentNode; n = n.parentNode) { } + return n; + } + + // Check if IntersectionObserver is available. + var intersectionObserverSupported = typeof IntersectionObserver !== 'undefined'; + var ResizeObserverSPI = /** @class */ (function () { /** - * Updates every observer from observers list and notifies them of queued - * entries. + * Creates a new instance of ResizeObserver. * - * @private - * @returns {boolean} Returns "true" if any observer has detected changes in - * dimensions of it's elements. + * @param {ResizeObserverCallback} callback - Callback function that is invoked + * when one of the observed elements changes it's content dimensions. + * @param {GlobalResizeObserverController} controller - Controller instance which + * is responsible for the updates of observer. + * @param {ResizeObserver} callbackCtx - Reference to the public + * ResizeObserver instance which will be passed to callback function. */ - ResizeObserverController.prototype.updateObservers_ = function () { - // Collect observers that have active observations. - var activeObservers = this.observers_.filter(function (observer) { - return observer.gatherActive(), observer.hasActive(); - }); - // Deliver notifications in a separate cycle in order to avoid any - // collisions between observers, e.g. when multiple instances of - // ResizeObserver are tracking the same element and the callback of one - // of them changes content dimensions of the observed target. Sometimes - // this may result in notifications being blocked for the rest of observers. - activeObservers.forEach(function (observer) { return observer.broadcastActive(); }); - return activeObservers.length > 0; - }; + function ResizeObserverSPI(callback, controller, callbackCtx) { + var _this = this; + /** + * Collection of resize observations that have detected changes in dimensions + * of elements. + * + * @private {Array} + */ + this.activeObservations_ = []; + /** + * Registry of the ResizeObservation instances. + * + * @private {Map} + */ + this.observations_ = new MapShim(); + /** + * The mapping between a root node and a set of targets tracked within + * this root node. + * + * @private {Map>} + */ + this.rootNodes_ = new MapShim(); + /** + * An instance of the intersection observer when available. There are a + * lot more browser versions that support the `IntersectionObserver`, but + * not the `ResizeObserver`. When `IntersectionObserver` is available it + * can be used to pick up DOM additions and removals more timely without + * significant costs. + * + * @private {IntersectionObserver} + */ + this.intersectionObserver_ = null; + if (typeof callback !== 'function') { + throw new TypeError('The callback provided as parameter 1 is not a function.'); + } + this.callback_ = callback; + this.controller_ = controller; + this.callbackCtx_ = callbackCtx; + if (intersectionObserverSupported) { + this.intersectionObserver_ = new IntersectionObserver(function () { return _this.checkRootChanges_(); }); + } + } /** - * Initializes DOM listeners. + * Starts observing provided element. * - * @private + * @param {Element} target - Element to be observed. * @returns {void} */ - ResizeObserverController.prototype.connect_ = function () { - // Do nothing if running in a non-browser environment or if listeners - // have been already added. - if (!isBrowser || this.connected_) { + ResizeObserverSPI.prototype.observe = function (target) { + if (!arguments.length) { + throw new TypeError('1 argument required, but only 0 present.'); + } + // Do nothing if current environment doesn't have the Element interface. + if (typeof Element === 'undefined' || !(Element instanceof Object)) { return; } - // Subscription to the "Transitionend" event is used as a workaround for - // delayed transitions. This way it's possible to capture at least the - // final state of an element. - document.addEventListener('transitionend', this.onTransitionEnd_); - window.addEventListener('resize', this.refresh); - if (mutationObserverSupported) { - this.mutationsObserver_ = new MutationObserver(this.refresh); - this.mutationsObserver_.observe(document, { - attributes: true, - childList: true, - characterData: true, - subtree: true - }); + if (!(target instanceof getWindowOf(target).Element)) { + throw new TypeError('parameter 1 is not of type "Element".'); } - else { - document.addEventListener('DOMSubtreeModified', this.refresh); - this.mutationEventsAdded_ = true; + var observations = this.observations_; + // Do nothing if element is already being observed. + if (observations.has(target)) { + return; } - this.connected_ = true; + var rootNode = getControlledRootNode(target, target.ownerDocument); + observations.set(target, new ResizeObservation(target, rootNode)); + var rootNodeTargets = this.rootNodes_.get(rootNode); + if (!rootNodeTargets) { + rootNodeTargets = []; + this.rootNodes_.set(rootNode, rootNodeTargets); + this.controller_.addObserver(rootNode, this); + } + rootNodeTargets.push(target); + if (this.intersectionObserver_) { + this.intersectionObserver_.observe(target); + } + // Force the update of observations. + this.controller_.refresh(rootNode); }; /** - * Removes DOM listeners. + * Stops observing provided element. * - * @private + * @param {Element} target - Element to stop observing. * @returns {void} */ - ResizeObserverController.prototype.disconnect_ = function () { - // Do nothing if running in a non-browser environment or if listeners - // have been already removed. - if (!isBrowser || !this.connected_) { + ResizeObserverSPI.prototype.unobserve = function (target) { + if (!arguments.length) { + throw new TypeError('1 argument required, but only 0 present.'); + } + // Do nothing if current environment doesn't have the Element interface. + if (typeof Element === 'undefined' || !(Element instanceof Object)) { return; } - document.removeEventListener('transitionend', this.onTransitionEnd_); - window.removeEventListener('resize', this.refresh); - if (this.mutationsObserver_) { - this.mutationsObserver_.disconnect(); + if (!(target instanceof getWindowOf(target).Element)) { + throw new TypeError('parameter 1 is not of type "Element".'); } - if (this.mutationEventsAdded_) { - document.removeEventListener('DOMSubtreeModified', this.refresh); + var observations = this.observations_; + var observation = observations.get(target); + // Do nothing if element is not being observed. + if (!observation) { + return; + } + observations.delete(target); + if (this.intersectionObserver_) { + this.intersectionObserver_.unobserve(target); + } + // Disconnect the root if no longer used. + var rootNode = observation.rootNode; + var rootNodeTargets = this.rootNodes_.get(rootNode); + if (rootNodeTargets) { + var index = rootNodeTargets.indexOf(target); + if (~index) { + rootNodeTargets.splice(index, 1); + } + if (rootNodeTargets.length === 0) { + this.rootNodes_.delete(rootNode); + this.controller_.removeObserver(rootNode, this); + } } - this.mutationsObserver_ = null; - this.mutationEventsAdded_ = false; - this.connected_ = false; }; /** - * "Transitionend" event handler. + * Stops observing all elements. * - * @private - * @param {TransitionEvent} event * @returns {void} */ - ResizeObserverController.prototype.onTransitionEnd_ = function (_a) { - var _b = _a.propertyName, propertyName = _b === void 0 ? '' : _b; - // Detect whether transition may affect dimensions of an element. - var isReflowProperty = transitionKeys.some(function (key) { - return !!~propertyName.indexOf(key); + ResizeObserverSPI.prototype.disconnect = function () { + var _this = this; + this.clearActive(); + this.observations_.clear(); + this.rootNodes_.forEach(function (_, rootNode) { + _this.controller_.removeObserver(rootNode, _this); }); - if (isReflowProperty) { - this.refresh(); + this.rootNodes_.clear(); + if (this.intersectionObserver_) { + this.intersectionObserver_.disconnect(); + this.intersectionObserver_ = null; } }; /** - * Returns instance of the ResizeObserverController. + * Collects observation instances the associated element of which has changed + * it's content rectangle. * - * @returns {ResizeObserverController} + * @returns {void} */ - ResizeObserverController.getInstance = function () { - if (!this.instance_) { - this.instance_ = new ResizeObserverController(); - } - return this.instance_; + ResizeObserverSPI.prototype.gatherActive = function () { + var _this = this; + this.checkRootChanges_(); + this.clearActive(); + this.observations_.forEach(function (observation) { + if (observation.isActive()) { + _this.activeObservations_.push(observation); + } + }); }; /** - * Holds reference to the controller's instance. + * Invokes initial callback function with a list of ResizeObserverEntry + * instances collected from active resize observations. * - * @private {ResizeObserverController} + * @returns {void} */ - ResizeObserverController.instance_ = null; - return ResizeObserverController; - }()); - - /** - * Defines non-writable/enumerable properties of the provided target object. - * - * @param {Object} target - Object for which to define properties. - * @param {Object} props - Properties to be defined. - * @returns {Object} Target object. - */ - var defineConfigurable = (function (target, props) { - for (var _i = 0, _a = Object.keys(props); _i < _a.length; _i++) { - var key = _a[_i]; - Object.defineProperty(target, key, { - value: props[key], - enumerable: false, - writable: false, - configurable: true + ResizeObserverSPI.prototype.broadcastActive = function () { + // Do nothing if observer doesn't have active observations. + if (!this.hasActive()) { + return; + } + var ctx = this.callbackCtx_; + // Create ResizeObserverEntry instance for every active observation. + var entries = this.activeObservations_.map(function (observation) { + return new ResizeObserverEntry(observation.target, observation.broadcastRect()); }); - } - return target; - }); - - /** - * Returns the global object associated with provided element. - * - * @param {Object} target - * @returns {Object} - */ - var getWindowOf = (function (target) { - // Assume that the element is an instance of Node, which means that it - // has the "ownerDocument" property from which we can retrieve a - // corresponding global object. - var ownerGlobal = target && target.ownerDocument && target.ownerDocument.defaultView; - // Return the local global object if it's not possible extract one from - // provided element. - return ownerGlobal || global$1; - }); - - // Placeholder of an empty content rectangle. - var emptyRect = createRectInit(0, 0, 0, 0); - /** - * Converts provided string to a number. - * - * @param {number|string} value - * @returns {number} - */ - function toFloat(value) { - return parseFloat(value) || 0; - } - /** - * Extracts borders size from provided styles. - * - * @param {CSSStyleDeclaration} styles - * @param {...string} positions - Borders positions (top, right, ...) - * @returns {number} - */ - function getBordersSize(styles) { - var positions = []; - for (var _i = 1; _i < arguments.length; _i++) { - positions[_i - 1] = arguments[_i]; - } - return positions.reduce(function (size, position) { - var value = styles['border-' + position + '-width']; - return size + toFloat(value); - }, 0); - } - /** - * Extracts paddings sizes from provided styles. - * - * @param {CSSStyleDeclaration} styles - * @returns {Object} Paddings box. - */ - function getPaddings(styles) { - var positions = ['top', 'right', 'bottom', 'left']; - var paddings = {}; - for (var _i = 0, positions_1 = positions; _i < positions_1.length; _i++) { - var position = positions_1[_i]; - var value = styles['padding-' + position]; - paddings[position] = toFloat(value); - } - return paddings; - } - /** - * Calculates content rectangle of provided SVG element. - * - * @param {SVGGraphicsElement} target - Element content rectangle of which needs - * to be calculated. - * @returns {DOMRectInit} - */ - function getSVGContentRect(target) { - var bbox = target.getBBox(); - return createRectInit(0, 0, bbox.width, bbox.height); - } + this.callback_.call(ctx, entries, ctx); + this.clearActive(); + }; + /** + * Clears the collection of active observations. + * + * @returns {void} + */ + ResizeObserverSPI.prototype.clearActive = function () { + this.activeObservations_.splice(0); + }; + /** + * Tells whether observer has active observations. + * + * @returns {boolean} + */ + ResizeObserverSPI.prototype.hasActive = function () { + return this.activeObservations_.length > 0; + }; + /** + * Check if any of the targets have changed the root node. For instance, + * an element could be moved from the main DOM to a shadow root. + * + * @private + * @returns {void} + */ + ResizeObserverSPI.prototype.checkRootChanges_ = function () { + var _this = this; + var changedRootTargets = null; + this.observations_.forEach(function (observation) { + var target = observation.target, oldRootNode = observation.rootNode; + var rootNode = getControlledRootNode(target, oldRootNode); + if (rootNode !== oldRootNode) { + if (!changedRootTargets) { + changedRootTargets = []; + } + changedRootTargets.push(target); + } + }); + if (changedRootTargets) { + changedRootTargets.forEach(function (target) { + _this.unobserve(target); + _this.observe(target); + }); + } + }; + return ResizeObserverSPI; + }()); /** - * Calculates content rectangle of provided HTMLElement. + * Find the most appropriate root node that should be monitored for events + * related to this target. * - * @param {HTMLElement} target - Element for which to calculate the content rectangle. - * @returns {DOMRectInit} + * @param {Node} target + * @param {Node} def + * @returns {Node} */ - function getHTMLElementContentRect(target) { - // Client width & height properties can't be - // used exclusively as they provide rounded values. - var clientWidth = target.clientWidth, clientHeight = target.clientHeight; - // By this condition we can catch all non-replaced inline, hidden and - // detached elements. Though elements with width & height properties less - // than 0.5 will be discarded as well. - // - // Without it we would need to implement separate methods for each of - // those cases and it's not possible to perform a precise and performance - // effective test for hidden elements. E.g. even jQuery's ':visible' filter - // gives wrong results for elements with width & height less than 0.5. - if (!clientWidth && !clientHeight) { - return emptyRect; - } - var styles = getWindowOf(target).getComputedStyle(target); - var paddings = getPaddings(styles); - var horizPad = paddings.left + paddings.right; - var vertPad = paddings.top + paddings.bottom; - // Computed styles of width & height are being used because they are the - // only dimensions available to JS that contain non-rounded values. It could - // be possible to utilize the getBoundingClientRect if only it's data wasn't - // affected by CSS transformations let alone paddings, borders and scroll bars. - var width = toFloat(styles.width), height = toFloat(styles.height); - // Width & height include paddings and borders when the 'border-box' box - // model is applied (except for IE). - if (styles.boxSizing === 'border-box') { - // Following conditions are required to handle Internet Explorer which - // doesn't include paddings and borders to computed CSS dimensions. - // - // We can say that if CSS dimensions + paddings are equal to the "client" - // properties then it's either IE, and thus we don't need to subtract - // anything, or an element merely doesn't have paddings/borders styles. - if (Math.round(width + horizPad) !== clientWidth) { - width -= getBordersSize(styles, 'left', 'right') + horizPad; - } - if (Math.round(height + vertPad) !== clientHeight) { - height -= getBordersSize(styles, 'top', 'bottom') + vertPad; - } - } - // Following steps can't be applied to the document's root element as its - // client[Width/Height] properties represent viewport area of the window. - // Besides, it's as well not necessary as the itself neither has - // rendered scroll bars nor it can be clipped. - if (!isDocumentElement(target)) { - // In some browsers (only in Firefox, actually) CSS width & height - // include scroll bars size which can be removed at this step as scroll - // bars are the only difference between rounded dimensions + paddings - // and "client" properties, though that is not always true in Chrome. - var vertScrollbar = Math.round(width + horizPad) - clientWidth; - var horizScrollbar = Math.round(height + vertPad) - clientHeight; - // Chrome has a rather weird rounding of "client" properties. - // E.g. for an element with content width of 314.2px it sometimes gives - // the client width of 315px and for the width of 314.7px it may give - // 314px. And it doesn't happen all the time. So just ignore this delta - // as a non-relevant. - if (Math.abs(vertScrollbar) !== 1) { - width -= vertScrollbar; - } - if (Math.abs(horizScrollbar) !== 1) { - height -= horizScrollbar; - } + function getControlledRootNode(target, def) { + var rootNode = getRootNode(target); + // DOCUMENT_NODE = 9 + // DOCUMENT_FRAGMENT_NODE = 11 (shadow root) + if (rootNode.nodeType === 9 || + rootNode.nodeType === 11) { + return rootNode; } - return createRectInit(paddings.left, paddings.top, width, height); + return def; } + /** - * Checks whether provided element is an instance of the SVGGraphicsElement. + * A shim for the requestAnimationFrame which falls back to the setTimeout if + * first one is not supported. * - * @param {Element} target - Element to be checked. - * @returns {boolean} + * @returns {number} Requests' identifier. */ - var isSVGGraphicsElement = (function () { - // Some browsers, namely IE and Edge, don't have the SVGGraphicsElement - // interface. - if (typeof SVGGraphicsElement !== 'undefined') { - return function (target) { return target instanceof getWindowOf(target).SVGGraphicsElement; }; + var requestAnimationFrame$1 = (function () { + if (typeof requestAnimationFrame === 'function') { + // It's required to use a bounded function because IE sometimes throws + // an "Invalid calling object" error if rAF is invoked without the global + // object on the left hand side. + return requestAnimationFrame.bind(global$1); } - // If it's so, then check that element is at least an instance of the - // SVGElement and that it has the "getBBox" method. - // eslint-disable-next-line no-extra-parens - return function (target) { return (target instanceof getWindowOf(target).SVGElement && - typeof target.getBBox === 'function'); }; + return function (callback) { return setTimeout(function () { return callback(Date.now()); }, 1000 / 60); }; })(); + + // Defines minimum timeout before adding a trailing call. + var trailingTimeout = 2; /** - * Checks whether provided element is a document element (). - * - * @param {Element} target - Element to be checked. - * @returns {boolean} - */ - function isDocumentElement(target) { - return target === getWindowOf(target).document.documentElement; - } - /** - * Calculates an appropriate content rectangle for provided html or svg element. + * Creates a wrapper function which ensures that provided callback will be + * invoked only once during the specified delay period. * - * @param {Element} target - Element content rectangle of which needs to be calculated. - * @returns {DOMRectInit} + * @param {Function} callback - Function to be invoked after the delay period. + * @param {number} delay - Delay after which to invoke callback. + * @returns {Function} */ - function getContentRect(target) { - if (!isBrowser) { - return emptyRect; + function throttle (callback, delay) { + var leadingCall = false, trailingCall = false, lastCallTime = 0; + /** + * Invokes the original callback function and schedules new invocation if + * the "proxy" was called during current request. + * + * @returns {void} + */ + function resolvePending() { + if (leadingCall) { + leadingCall = false; + callback(); + } + if (trailingCall) { + proxy(); + } } - if (isSVGGraphicsElement(target)) { - return getSVGContentRect(target); + /** + * Callback invoked after the specified delay. It will further postpone + * invocation of the original function delegating it to the + * requestAnimationFrame. + * + * @returns {void} + */ + function timeoutCallback() { + requestAnimationFrame$1(resolvePending); } - return getHTMLElementContentRect(target); - } - /** - * Creates rectangle with an interface of the DOMRectReadOnly. - * Spec: https://drafts.fxtf.org/geometry/#domrectreadonly - * - * @param {DOMRectInit} rectInit - Object with rectangle's x/y coordinates and dimensions. - * @returns {DOMRectReadOnly} - */ - function createReadOnlyRect(_a) { - var x = _a.x, y = _a.y, width = _a.width, height = _a.height; - // If DOMRectReadOnly is available use it as a prototype for the rectangle. - var Constr = typeof DOMRectReadOnly !== 'undefined' ? DOMRectReadOnly : Object; - var rect = Object.create(Constr.prototype); - // Rectangle's properties are not writable and non-enumerable. - defineConfigurable(rect, { - x: x, y: y, width: width, height: height, - top: y, - right: x + width, - bottom: height + y, - left: x - }); - return rect; - } - /** - * Creates DOMRectInit object based on the provided dimensions and the x/y coordinates. - * Spec: https://drafts.fxtf.org/geometry/#dictdef-domrectinit - * - * @param {number} x - X coordinate. - * @param {number} y - Y coordinate. - * @param {number} width - Rectangle's width. - * @param {number} height - Rectangle's height. - * @returns {DOMRectInit} - */ - function createRectInit(x, y, width, height) { - return { x: x, y: y, width: width, height: height }; + /** + * Schedules invocation of the original function. + * + * @returns {void} + */ + function proxy() { + var timeStamp = Date.now(); + if (leadingCall) { + // Reject immediately following calls. + if (timeStamp - lastCallTime < trailingTimeout) { + return; + } + // Schedule new call to be in invoked when the pending one is resolved. + // This is important for "transitions" which never actually start + // immediately so there is a chance that we might miss one if change + // happens amids the pending invocation. + trailingCall = true; + } + else { + leadingCall = true; + trailingCall = false; + setTimeout(timeoutCallback, delay); + } + lastCallTime = timeStamp; + } + return proxy; } + // Minimum delay before invoking the update of observers. + var REFRESH_DELAY = 20; + // A list of substrings of CSS properties used to find transition events that + // might affect dimensions of observed elements. + var transitionKeys = ['top', 'right', 'bottom', 'left', 'width', 'height', 'size', 'weight']; + // Check if MutationObserver is available. + var mutationObserverSupported = typeof MutationObserver !== 'undefined'; /** - * Class that is responsible for computations of the content rectangle of - * provided DOM element and for keeping track of it's changes. + * The controller that tracks the resize-related events for the specified + * root node. The `GlobalResizeObserverController` uses a per-root-node + * instance of this class to track mutations and other events within the + * specified root. */ - var ResizeObservation = /** @class */ (function () { + var ResizeObserverController = /** @class */ (function () { /** - * Creates an instance of ResizeObservation. + * Creates a new instance of ResizeObserverController. * - * @param {Element} target - Element to be observed. + * @private + * @param {Node} rootNode - The root node that this controller monitors. + * @param {GlobalResizeObserverController} globalController - The global + * controller for all roots. */ - function ResizeObservation(target) { + function ResizeObserverController(rootNode, globalController) { /** - * Broadcasted width of content rectangle. + * The root node that this controller monitors. * - * @type {number} + * @private {Node} */ - this.broadcastWidth = 0; + this.rootNode_ = null; /** - * Broadcasted height of content rectangle. + * The global controller. * - * @type {number} + * @private {GlobalResizeObserverController} */ - this.broadcastHeight = 0; + this.globalController_ = null; /** - * Reference to the last observed content rectangle. + * Indicates whether DOM listeners have been added. * - * @private {DOMRectInit} + * @private {boolean} */ - this.contentRect_ = createRectInit(0, 0, 0, 0); - this.target = target; + this.connected_ = false; + /** + * Tells that controller has subscribed for Mutation Events. + * + * @private {boolean} + */ + this.mutationEventsAdded_ = false; + /** + * Keeps reference to the instance of MutationObserver. + * + * @private {MutationObserver} + */ + this.mutationsObserver_ = null; + /** + * Monitors the shadow root host for size changes. + * + * @private {ResizeObserverSPI} + */ + this.hostObserver_ = null; + /** + * A list of connected observers. + * + * @private {Array} + */ + this.observers_ = []; + this.rootNode_ = rootNode; + this.globalController_ = globalController; + this.onTransitionEnd_ = this.onTransitionEnd_.bind(this); + this.refresh = throttle(this.refresh.bind(this), REFRESH_DELAY); } /** - * Updates content rectangle and tells whether it's width or height properties - * have changed since the last broadcast. + * Adds observer to observers list. * - * @returns {boolean} + * @param {ResizeObserverSPI} observer - Observer to be added. + * @returns {void} */ - ResizeObservation.prototype.isActive = function () { - var rect = getContentRect(this.target); - this.contentRect_ = rect; - return (rect.width !== this.broadcastWidth || - rect.height !== this.broadcastHeight); + ResizeObserverController.prototype.addObserver = function (observer) { + if (!~this.observers_.indexOf(observer)) { + this.observers_.push(observer); + } + // Add listeners if they haven't been added yet. + if (!this.connected_) { + this.connect_(); + } }; /** - * Updates 'broadcastWidth' and 'broadcastHeight' properties with a data - * from the corresponding properties of the last observed content rectangle. + * Removes observer from observers list. * - * @returns {DOMRectInit} Last observed content rectangle. + * @param {ResizeObserverSPI} observer - Observer to be removed. + * @returns {void} */ - ResizeObservation.prototype.broadcastRect = function () { - var rect = this.contentRect_; - this.broadcastWidth = rect.width; - this.broadcastHeight = rect.height; - return rect; + ResizeObserverController.prototype.removeObserver = function (observer) { + var observers = this.observers_; + var index = observers.indexOf(observer); + // Remove observer if it's present in registry. + if (~index) { + observers.splice(index, 1); + } + // Remove listeners if controller has no connected observers. + if (!observers.length && this.connected_) { + this.disconnect_(); + } }; - return ResizeObservation; - }()); - - var ResizeObserverEntry = /** @class */ (function () { /** - * Creates an instance of ResizeObserverEntry. + * Invokes the update of observers. It will continue running updates insofar + * it detects changes. * - * @param {Element} target - Element that is being observed. - * @param {DOMRectInit} rectInit - Data of the element's content rectangle. + * @returns {void} */ - function ResizeObserverEntry(target, rectInit) { - var contentRect = createReadOnlyRect(rectInit); - // According to the specification following properties are not writable - // and are also not enumerable in the native implementation. - // - // Property accessors are not being used as they'd require to define a - // private WeakMap storage which may cause memory leaks in browsers that - // don't support this type of collections. - defineConfigurable(this, { target: target, contentRect: contentRect }); - } - return ResizeObserverEntry; - }()); - - var ResizeObserverSPI = /** @class */ (function () { + ResizeObserverController.prototype.refresh = function () { + var changesDetected = this.updateObservers_(); + // Continue running updates if changes have been detected as there might + // be future ones caused by CSS transitions. + if (changesDetected) { + this.refresh(); + } + }; /** - * Creates a new instance of ResizeObserver. + * Updates every observer from observers list and notifies them of queued + * entries. * - * @param {ResizeObserverCallback} callback - Callback function that is invoked - * when one of the observed elements changes it's content dimensions. - * @param {ResizeObserverController} controller - Controller instance which - * is responsible for the updates of observer. - * @param {ResizeObserver} callbackCtx - Reference to the public - * ResizeObserver instance which will be passed to callback function. + * @private + * @returns {boolean} Returns "true" if any observer has detected changes in + * dimensions of it's elements. */ - function ResizeObserverSPI(callback, controller, callbackCtx) { - /** - * Collection of resize observations that have detected changes in dimensions - * of elements. - * - * @private {Array} - */ - this.activeObservations_ = []; - /** - * Registry of the ResizeObservation instances. - * - * @private {Map} - */ - this.observations_ = new MapShim(); - if (typeof callback !== 'function') { - throw new TypeError('The callback provided as parameter 1 is not a function.'); - } - this.callback_ = callback; - this.controller_ = controller; - this.callbackCtx_ = callbackCtx; - } + ResizeObserverController.prototype.updateObservers_ = function () { + // Collect observers that have active observations. + var activeObservers = this.observers_.filter(function (observer) { + return observer.gatherActive(), observer.hasActive(); + }); + // Deliver notifications in a separate cycle in order to avoid any + // collisions between observers, e.g. when multiple instances of + // ResizeObserver are tracking the same element and the callback of one + // of them changes content dimensions of the observed target. Sometimes + // this may result in notifications being blocked for the rest of observers. + activeObservers.forEach(function (observer) { return observer.broadcastActive(); }); + return activeObservers.length > 0; + }; /** - * Starts observing provided element. + * Initializes DOM listeners. * - * @param {Element} target - Element to be observed. + * @private * @returns {void} */ - ResizeObserverSPI.prototype.observe = function (target) { - if (!arguments.length) { - throw new TypeError('1 argument required, but only 0 present.'); - } - // Do nothing if current environment doesn't have the Element interface. - if (typeof Element === 'undefined' || !(Element instanceof Object)) { + ResizeObserverController.prototype.connect_ = function () { + // Do nothing if running in a non-browser environment or if listeners + // have been already added. + if (!isBrowser || this.connected_) { return; } - if (!(target instanceof getWindowOf(target).Element)) { - throw new TypeError('parameter 1 is not of type "Element".'); + var rootNode = this.rootNode_; + var doc = rootNode.ownerDocument || rootNode; + var win = doc.defaultView; + // Subscription to the "Transitionend" event is used as a workaround for + // delayed transitions. This way it's possible to capture at least the + // final state of an element. + rootNode.addEventListener('transitionend', this.onTransitionEnd_, true); + if (win) { + win.addEventListener('resize', this.refresh, true); } - var observations = this.observations_; - // Do nothing if element is already being observed. - if (observations.has(target)) { - return; + if (mutationObserverSupported) { + this.mutationsObserver_ = new MutationObserver(this.refresh); + this.mutationsObserver_.observe(rootNode, { + attributes: true, + childList: true, + characterData: true, + subtree: true + }); } - observations.set(target, new ResizeObservation(target)); - this.controller_.addObserver(this); - // Force the update of observations. - this.controller_.refresh(); + else { + rootNode.addEventListener('DOMSubtreeModified', this.refresh, true); + this.mutationEventsAdded_ = true; + } + // It's a shadow root. Monitor the host. + if (this.rootNode_.host) { + this.hostObserver_ = new ResizeObserverSPI(this.refresh, this.globalController_, this); + this.hostObserver_.observe(this.rootNode_.host); + } + this.connected_ = true; }; /** - * Stops observing provided element. + * Removes DOM listeners. * - * @param {Element} target - Element to stop observing. + * @private * @returns {void} */ - ResizeObserverSPI.prototype.unobserve = function (target) { - if (!arguments.length) { - throw new TypeError('1 argument required, but only 0 present.'); - } - // Do nothing if current environment doesn't have the Element interface. - if (typeof Element === 'undefined' || !(Element instanceof Object)) { + ResizeObserverController.prototype.disconnect_ = function () { + // Do nothing if running in a non-browser environment or if listeners + // have been already removed. + if (!isBrowser || !this.connected_) { return; } - if (!(target instanceof getWindowOf(target).Element)) { - throw new TypeError('parameter 1 is not of type "Element".'); + var rootNode = this.rootNode_; + var doc = rootNode.ownerDocument || rootNode; + var win = doc.defaultView; + rootNode.removeEventListener('transitionend', this.onTransitionEnd_, true); + if (win) { + win.removeEventListener('resize', this.refresh, true); } - var observations = this.observations_; - // Do nothing if element is not being observed. - if (!observations.has(target)) { - return; + if (this.mutationsObserver_) { + this.mutationsObserver_.disconnect(); } - observations.delete(target); - if (!observations.size) { - this.controller_.removeObserver(this); + if (this.mutationEventsAdded_) { + rootNode.removeEventListener('DOMSubtreeModified', this.refresh, true); } + if (this.hostObserver_) { + this.hostObserver_.disconnect(); + } + this.hostObserver_ = null; + this.mutationsObserver_ = null; + this.mutationEventsAdded_ = false; + this.connected_ = false; }; /** - * Stops observing all elements. + * "Transitionend" event handler. * + * @private + * @param {TransitionEvent} event * @returns {void} */ - ResizeObserverSPI.prototype.disconnect = function () { - this.clearActive(); - this.observations_.clear(); - this.controller_.removeObserver(this); + ResizeObserverController.prototype.onTransitionEnd_ = function (_a) { + var _b = _a.propertyName, propertyName = _b === void 0 ? '' : _b; + // Detect whether transition may affect dimensions of an element. + var isReflowProperty = transitionKeys.some(function (key) { + return !!~propertyName.indexOf(key); + }); + if (isReflowProperty) { + this.refresh(); + } }; + return ResizeObserverController; + }()); + + /** + * Singleton controller class which handles updates of ResizeObserver instances. + */ + var GlobalResizeObserverController = /** @class */ (function () { + function GlobalResizeObserverController() { + /** + * A mapping from a DOM root node and a respective controller. A root node + * could be the main document, a same-origin iframe, or a shadow root. + * See https://developer.mozilla.org/en-US/docs/Web/API/Node/getRootNode + * for more info. + * + * @private {Map} + */ + this.rootNodeControllers_ = typeof WeakMap !== 'undefined' ? new WeakMap() : new Map(); + } /** - * Collects observation instances the associated element of which has changed - * it's content rectangle. + * Adds observer to observers list. * + * @param {Node} rootNode - The root node for which the observer is added. + * @param {ResizeObserverSPI} observer - Observer to be added. * @returns {void} */ - ResizeObserverSPI.prototype.gatherActive = function () { - var _this = this; - this.clearActive(); - this.observations_.forEach(function (observation) { - if (observation.isActive()) { - _this.activeObservations_.push(observation); - } - }); + GlobalResizeObserverController.prototype.addObserver = function (rootNode, observer) { + var rootNodeController = this.rootNodeControllers_.get(rootNode); + if (!rootNodeController) { + rootNodeController = new ResizeObserverController(rootNode, this); + this.rootNodeControllers_.set(rootNode, rootNodeController); + } + rootNodeController.addObserver(observer); }; /** - * Invokes initial callback function with a list of ResizeObserverEntry - * instances collected from active resize observations. + * Removes observer from observers list. * + * @param {Node} rootNode - The root node from which the observer is removed. + * @param {ResizeObserverSPI} observer - Observer to be removed. * @returns {void} */ - ResizeObserverSPI.prototype.broadcastActive = function () { - // Do nothing if observer doesn't have active observations. - if (!this.hasActive()) { - return; + GlobalResizeObserverController.prototype.removeObserver = function (rootNode, observer) { + var rootNodeController = this.rootNodeControllers_.get(rootNode); + if (rootNodeController) { + rootNodeController.removeObserver(observer); } - var ctx = this.callbackCtx_; - // Create ResizeObserverEntry instance for every active observation. - var entries = this.activeObservations_.map(function (observation) { - return new ResizeObserverEntry(observation.target, observation.broadcastRect()); - }); - this.callback_.call(ctx, entries, ctx); - this.clearActive(); }; /** - * Clears the collection of active observations. + * Invokes the update of observers. It will continue running updates insofar + * it detects changes. * + * @param {Node} rootNode - The root node to refresh. * @returns {void} */ - ResizeObserverSPI.prototype.clearActive = function () { - this.activeObservations_.splice(0); + GlobalResizeObserverController.prototype.refresh = function (rootNode) { + var rootNodeController = this.rootNodeControllers_.get(rootNode); + if (rootNodeController) { + rootNodeController.refresh(); + } }; /** - * Tells whether observer has active observations. + * Returns instance of the GlobalResizeObserverController. * - * @returns {boolean} + * @returns {GlobalResizeObserverController} */ - ResizeObserverSPI.prototype.hasActive = function () { - return this.activeObservations_.length > 0; + GlobalResizeObserverController.getInstance = function () { + if (!this.instance_) { + this.instance_ = new GlobalResizeObserverController(); + } + return this.instance_; }; - return ResizeObserverSPI; + /** + * Holds reference to the controller's instance. + * + * @private {GlobalResizeObserverController} + */ + GlobalResizeObserverController.instance_ = null; + return GlobalResizeObserverController; }()); // Registry of internal observers. If WeakMap is not available use current shim @@ -905,7 +1132,7 @@ if (!arguments.length) { throw new TypeError('1 argument required, but only 0 present.'); } - var controller = ResizeObserverController.getInstance(); + var controller = GlobalResizeObserverController.getInstance(); var observer = new ResizeObserverSPI(callback, controller, this); observers.set(this, observer); } diff --git a/package-lock.json b/package-lock.json index 9a5f71f..c64b35b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "resize-observer-polyfill", - "version": "1.5.0", + "version": "1.5.2", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1969,7 +1969,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -1990,12 +1991,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2010,17 +2013,20 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -2137,7 +2143,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -2149,6 +2156,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -2163,6 +2171,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -2170,12 +2179,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.2.4", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -2194,6 +2205,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -2274,7 +2286,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -2286,6 +2299,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -2371,7 +2385,8 @@ "safe-buffer": { "version": "5.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -2407,6 +2422,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -2426,6 +2442,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -2469,12 +2486,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, diff --git a/package.json b/package.json index 8d7cebc..c14b041 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "resize-observer-polyfill", "author": "Denis Rul ", - "version": "1.5.1", + "version": "1.5.2", "description": "A polyfill for the Resize Observer API", "main": "dist/ResizeObserver.js", "module": "dist/ResizeObserver.es.js", diff --git a/src/GlobalResizeObserverController.js b/src/GlobalResizeObserverController.js new file mode 100644 index 0000000..cfbd7b8 --- /dev/null +++ b/src/GlobalResizeObserverController.js @@ -0,0 +1,83 @@ +import ResizeObserverController from './ResizeObserverController.js'; + +/** + * Singleton controller class which handles updates of ResizeObserver instances. + */ +export default class GlobalResizeObserverController { + /** + * A mapping from a DOM root node and a respective controller. A root node + * could be the main document, a same-origin iframe, or a shadow root. + * See https://developer.mozilla.org/en-US/docs/Web/API/Node/getRootNode + * for more info. + * + * @private {Map} + */ + rootNodeControllers_ = typeof WeakMap !== 'undefined' ? new WeakMap() : new Map(); + + /** + * Holds reference to the controller's instance. + * + * @private {GlobalResizeObserverController} + */ + static instance_ = null; + + /** + * Adds observer to observers list. + * + * @param {Node} rootNode - The root node for which the observer is added. + * @param {ResizeObserverSPI} observer - Observer to be added. + * @returns {void} + */ + addObserver(rootNode, observer) { + let rootNodeController = this.rootNodeControllers_.get(rootNode); + + if (!rootNodeController) { + rootNodeController = new ResizeObserverController(rootNode, this); + this.rootNodeControllers_.set(rootNode, rootNodeController); + } + rootNodeController.addObserver(observer); + } + + /** + * Removes observer from observers list. + * + * @param {Node} rootNode - The root node from which the observer is removed. + * @param {ResizeObserverSPI} observer - Observer to be removed. + * @returns {void} + */ + removeObserver(rootNode, observer) { + const rootNodeController = this.rootNodeControllers_.get(rootNode); + + if (rootNodeController) { + rootNodeController.removeObserver(observer); + } + } + + /** + * Invokes the update of observers. It will continue running updates insofar + * it detects changes. + * + * @param {Node} rootNode - The root node to refresh. + * @returns {void} + */ + refresh(rootNode) { + const rootNodeController = this.rootNodeControllers_.get(rootNode); + + if (rootNodeController) { + rootNodeController.refresh(); + } + } + + /** + * Returns instance of the GlobalResizeObserverController. + * + * @returns {GlobalResizeObserverController} + */ + static getInstance() { + if (!this.instance_) { + this.instance_ = new GlobalResizeObserverController(); + } + + return this.instance_; + } +} diff --git a/src/ResizeObservation.js b/src/ResizeObservation.js index f1d0b39..8e0f720 100644 --- a/src/ResizeObservation.js +++ b/src/ResizeObservation.js @@ -12,6 +12,15 @@ export default class ResizeObservation { */ target; + /** + * The root node of the observed element at the time of subscription. If + * the root node of the element changes, the `ResizeObserver` implementation + * will resubscribe. + * + * @type {Node} + */ + rootNode; + /** * Broadcasted width of content rectangle. * @@ -37,9 +46,12 @@ export default class ResizeObservation { * Creates an instance of ResizeObservation. * * @param {Element} target - Element to be observed. + * @param {Node} rootNode - The root node of the element at the time + * of subscription. */ - constructor(target) { + constructor(target, rootNode) { this.target = target; + this.rootNode = rootNode; } /** diff --git a/src/ResizeObserver.js b/src/ResizeObserver.js index 4309db0..b3a9bab 100644 --- a/src/ResizeObserver.js +++ b/src/ResizeObserver.js @@ -1,5 +1,5 @@ +import GlobalResizeObserverController from './GlobalResizeObserverController.js'; import {Map} from './shims/es6-collections.js'; -import ResizeObserverController from './ResizeObserverController.js'; import ResizeObserverSPI from './ResizeObserverSPI.js'; // Registry of internal observers. If WeakMap is not available use current shim @@ -26,7 +26,7 @@ class ResizeObserver { throw new TypeError('1 argument required, but only 0 present.'); } - const controller = ResizeObserverController.getInstance(); + const controller = GlobalResizeObserverController.getInstance(); const observer = new ResizeObserverSPI(callback, controller, this); observers.set(this, observer); diff --git a/src/ResizeObserverController.js b/src/ResizeObserverController.js index 6fbc76d..3ba45ea 100644 --- a/src/ResizeObserverController.js +++ b/src/ResizeObserverController.js @@ -1,3 +1,4 @@ +import ResizeObserverSPI from './ResizeObserverSPI'; import isBrowser from './utils/isBrowser.js'; import throttle from './utils/throttle.js'; @@ -12,9 +13,26 @@ const transitionKeys = ['top', 'right', 'bottom', 'left', 'width', 'height', 'si const mutationObserverSupported = typeof MutationObserver !== 'undefined'; /** - * Singleton controller class which handles updates of ResizeObserver instances. + * The controller that tracks the resize-related events for the specified + * root node. The `GlobalResizeObserverController` uses a per-root-node + * instance of this class to track mutations and other events within the + * specified root. */ export default class ResizeObserverController { + /** + * The root node that this controller monitors. + * + * @private {Node} + */ + rootNode_ = null; + + /** + * The global controller. + * + * @private {GlobalResizeObserverController} + */ + globalController_ = null; + /** * Indicates whether DOM listeners have been added. * @@ -37,25 +55,30 @@ export default class ResizeObserverController { mutationsObserver_ = null; /** - * A list of connected observers. + * Monitors the shadow root host for size changes. * - * @private {Array} + * @private {ResizeObserverSPI} */ - observers_ = []; + hostObserver_ = null; /** - * Holds reference to the controller's instance. + * A list of connected observers. * - * @private {ResizeObserverController} + * @private {Array} */ - static instance_ = null; + observers_ = []; /** * Creates a new instance of ResizeObserverController. * * @private + * @param {Node} rootNode - The root node that this controller monitors. + * @param {GlobalResizeObserverController} globalController - The global + * controller for all roots. */ - constructor() { + constructor(rootNode, globalController) { + this.rootNode_ = rootNode; + this.globalController_ = globalController; this.onTransitionEnd_ = this.onTransitionEnd_.bind(this); this.refresh = throttle(this.refresh.bind(this), REFRESH_DELAY); } @@ -151,28 +174,40 @@ export default class ResizeObserverController { return; } + const rootNode = this.rootNode_; + const doc = rootNode.ownerDocument || rootNode; + const win = doc.defaultView; + // Subscription to the "Transitionend" event is used as a workaround for // delayed transitions. This way it's possible to capture at least the // final state of an element. - document.addEventListener('transitionend', this.onTransitionEnd_); + rootNode.addEventListener('transitionend', this.onTransitionEnd_, true); - window.addEventListener('resize', this.refresh); + if (win) { + win.addEventListener('resize', this.refresh, true); + } if (mutationObserverSupported) { this.mutationsObserver_ = new MutationObserver(this.refresh); - this.mutationsObserver_.observe(document, { + this.mutationsObserver_.observe(rootNode, { attributes: true, childList: true, characterData: true, subtree: true }); } else { - document.addEventListener('DOMSubtreeModified', this.refresh); + rootNode.addEventListener('DOMSubtreeModified', this.refresh, true); this.mutationEventsAdded_ = true; } + // It's a shadow root. Monitor the host. + if (this.rootNode_.host) { + this.hostObserver_ = new ResizeObserverSPI(this.refresh, this.globalController_, this); + this.hostObserver_.observe(this.rootNode_.host); + } + this.connected_ = true; } @@ -189,17 +224,29 @@ export default class ResizeObserverController { return; } - document.removeEventListener('transitionend', this.onTransitionEnd_); - window.removeEventListener('resize', this.refresh); + const rootNode = this.rootNode_; + const doc = rootNode.ownerDocument || rootNode; + const win = doc.defaultView; + + rootNode.removeEventListener('transitionend', this.onTransitionEnd_, true); + + if (win) { + win.removeEventListener('resize', this.refresh, true); + } if (this.mutationsObserver_) { this.mutationsObserver_.disconnect(); } if (this.mutationEventsAdded_) { - document.removeEventListener('DOMSubtreeModified', this.refresh); + rootNode.removeEventListener('DOMSubtreeModified', this.refresh, true); + } + + if (this.hostObserver_) { + this.hostObserver_.disconnect(); } + this.hostObserver_ = null; this.mutationsObserver_ = null; this.mutationEventsAdded_ = false; this.connected_ = false; @@ -222,17 +269,4 @@ export default class ResizeObserverController { this.refresh(); } } - - /** - * Returns instance of the ResizeObserverController. - * - * @returns {ResizeObserverController} - */ - static getInstance() { - if (!this.instance_) { - this.instance_ = new ResizeObserverController(); - } - - return this.instance_; - } } diff --git a/src/ResizeObserverSPI.js b/src/ResizeObserverSPI.js index d468b75..169ea52 100644 --- a/src/ResizeObserverSPI.js +++ b/src/ResizeObserverSPI.js @@ -1,8 +1,12 @@ import {Map} from './shims/es6-collections.js'; import ResizeObservation from './ResizeObservation.js'; import ResizeObserverEntry from './ResizeObserverEntry.js'; +import getRootNode from './shims/getRootNode'; import getWindowOf from './utils/getWindowOf.js'; +// Check if IntersectionObserver is available. +const intersectionObserverSupported = typeof IntersectionObserver !== 'undefined'; + export default class ResizeObserverSPI { /** * Collection of resize observations that have detected changes in dimensions @@ -28,9 +32,9 @@ export default class ResizeObserverSPI { callbackCtx_; /** - * Reference to the associated ResizeObserverController. + * Reference to the associated GlobalResizeObserverController. * - * @private {ResizeObserverController} + * @private {GlobalResizeObserverController} */ controller_; @@ -41,12 +45,31 @@ export default class ResizeObserverSPI { */ observations_ = new Map(); + /** + * The mapping between a root node and a set of targets tracked within + * this root node. + * + * @private {Map>} + */ + rootNodes_ = new Map(); + + /** + * An instance of the intersection observer when available. There are a + * lot more browser versions that support the `IntersectionObserver`, but + * not the `ResizeObserver`. When `IntersectionObserver` is available it + * can be used to pick up DOM additions and removals more timely without + * significant costs. + * + * @private {IntersectionObserver} + */ + intersectionObserver_ = null; + /** * Creates a new instance of ResizeObserver. * * @param {ResizeObserverCallback} callback - Callback function that is invoked * when one of the observed elements changes it's content dimensions. - * @param {ResizeObserverController} controller - Controller instance which + * @param {GlobalResizeObserverController} controller - Controller instance which * is responsible for the updates of observer. * @param {ResizeObserver} callbackCtx - Reference to the public * ResizeObserver instance which will be passed to callback function. @@ -59,6 +82,10 @@ export default class ResizeObserverSPI { this.callback_ = callback; this.controller_ = controller; this.callbackCtx_ = callbackCtx; + + if (intersectionObserverSupported) { + this.intersectionObserver_ = new IntersectionObserver(() => this.checkRootChanges_()); + } } /** @@ -88,12 +115,25 @@ export default class ResizeObserverSPI { return; } - observations.set(target, new ResizeObservation(target)); + const rootNode = getControlledRootNode(target, target.ownerDocument); - this.controller_.addObserver(this); + observations.set(target, new ResizeObservation(target, rootNode)); + + let rootNodeTargets = this.rootNodes_.get(rootNode); + + if (!rootNodeTargets) { + rootNodeTargets = []; + this.rootNodes_.set(rootNode, rootNodeTargets); + this.controller_.addObserver(rootNode, this); + } + rootNodeTargets.push(target); + + if (this.intersectionObserver_) { + this.intersectionObserver_.observe(target); + } // Force the update of observations. - this.controller_.refresh(); + this.controller_.refresh(rootNode); } /** @@ -117,16 +157,33 @@ export default class ResizeObserverSPI { } const observations = this.observations_; + const observation = observations.get(target); // Do nothing if element is not being observed. - if (!observations.has(target)) { + if (!observation) { return; } observations.delete(target); - if (!observations.size) { - this.controller_.removeObserver(this); + if (this.intersectionObserver_) { + this.intersectionObserver_.unobserve(target); + } + + // Disconnect the root if no longer used. + const {rootNode} = observation; + const rootNodeTargets = this.rootNodes_.get(rootNode); + + if (rootNodeTargets) { + const index = rootNodeTargets.indexOf(target); + + if (~index) { + rootNodeTargets.splice(index, 1); + } + if (rootNodeTargets.length === 0) { + this.rootNodes_.delete(rootNode); + this.controller_.removeObserver(rootNode, this); + } } } @@ -138,7 +195,14 @@ export default class ResizeObserverSPI { disconnect() { this.clearActive(); this.observations_.clear(); - this.controller_.removeObserver(this); + this.rootNodes_.forEach((_, rootNode) => { + this.controller_.removeObserver(rootNode, this); + }); + this.rootNodes_.clear(); + if (this.intersectionObserver_) { + this.intersectionObserver_.disconnect(); + this.intersectionObserver_ = null; + } } /** @@ -148,6 +212,8 @@ export default class ResizeObserverSPI { * @returns {void} */ gatherActive() { + this.checkRootChanges_(); + this.clearActive(); this.observations_.forEach(observation => { @@ -200,4 +266,55 @@ export default class ResizeObserverSPI { hasActive() { return this.activeObservations_.length > 0; } + + /** + * Check if any of the targets have changed the root node. For instance, + * an element could be moved from the main DOM to a shadow root. + * + * @private + * @returns {void} + */ + checkRootChanges_() { + let changedRootTargets = null; + + this.observations_.forEach(observation => { + const {target, rootNode: oldRootNode} = observation; + const rootNode = getControlledRootNode(target, oldRootNode); + + if (rootNode !== oldRootNode) { + if (!changedRootTargets) { + changedRootTargets = []; + } + changedRootTargets.push(target); + } + }); + + if (changedRootTargets) { + changedRootTargets.forEach(target => { + this.unobserve(target); + this.observe(target); + }); + } + } +} + +/** + * Find the most appropriate root node that should be monitored for events + * related to this target. + * + * @param {Node} target + * @param {Node} def + * @returns {Node} + */ +function getControlledRootNode(target, def) { + const rootNode = getRootNode(target); + + // DOCUMENT_NODE = 9 + // DOCUMENT_FRAGMENT_NODE = 11 (shadow root) + if (rootNode.nodeType === 9 || + rootNode.nodeType === 11) { + return rootNode; + } + + return def; } diff --git a/src/shims/getRootNode.js b/src/shims/getRootNode.js new file mode 100644 index 0000000..65f6a6d --- /dev/null +++ b/src/shims/getRootNode.js @@ -0,0 +1,20 @@ +/** + * A shim for the `Node.getRootNode()` API. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/Node/getRootNode for + * more info. + * + * @param {Node} node + * @returns {Node} + */ +export default function getRootNode(node) { + if (typeof node.getRootNode === 'function') { + return node.getRootNode(); + } + let n; + + // eslint-disable-next-line no-empty + for (n = node; n.parentNode; n = n.parentNode) {} + + return n; +} diff --git a/tests/ResizeObserver.spec.js b/tests/ResizeObserver.spec.js index 4f4631a..b2ef509 100644 --- a/tests/ResizeObserver.spec.js +++ b/tests/ResizeObserver.spec.js @@ -43,47 +43,48 @@ const template = ` const timeout = 300; -function appendStyles() { +function appendStyles(documentOrShadow) { + const head = documentOrShadow.head || documentOrShadow; + styles = document.createElement('style'); styles.id = 'styles'; - document.head.appendChild(styles); + head.appendChild(styles); styles.innerHTML = css; } -function removeStyles() { - document.head.removeChild(styles); +function removeStyles(documentOrShadow) { + const head = documentOrShadow.head || documentOrShadow; + + head.removeChild(styles); styles = null; } -function appendElements() { - document.body.insertAdjacentHTML('beforeend', template); +function appendElements(documentOrShadow, body) { + body.insertAdjacentHTML('beforeend', template); elements = { - root: document.getElementById('root'), - container: document.getElementById('container'), - target1: document.getElementById('target1'), - target2: document.getElementById('target2'), - target3: document.getElementById('target3') + documentOrShadow, + body, + root: documentOrShadow.getElementById('root'), + container: documentOrShadow.getElementById('container'), + target1: documentOrShadow.getElementById('target1'), + target2: documentOrShadow.getElementById('target2'), + target3: documentOrShadow.getElementById('target3') }; } -function removeElements() { - if (document.body.contains(elements.root)) { - document.body.removeChild(elements.root); +function removeElements(documentOrShadow, body) { + if (body.contains(elements.root)) { + body.removeChild(elements.root); } elements = {}; } describe('ResizeObserver', () => { - beforeEach(() => { - appendStyles(); - appendElements(); - }); - afterEach(() => { if (observer) { observer.disconnect(); @@ -94,9 +95,6 @@ describe('ResizeObserver', () => { observer2.disconnect(); observer2 = null; } - - removeStyles(); - removeElements(); }); describe('constructor', () => { @@ -124,961 +122,841 @@ describe('ResizeObserver', () => { /* eslint-enable no-new */ }); - describe('observe', () => { - it('throws an error if no arguments are provided', () => { - observer = new ResizeObserver(emptyFn); - - expect(() => { - observer.observe(); - }).toThrowError(/1 argument required/i); - }); - - it('throws an error if target is not an Element', () => { - observer = new ResizeObserver(emptyFn); + describe('API shape', () => { + describe('observe', () => { + it('throws an error if no arguments are provided', () => { + observer = new ResizeObserver(emptyFn); - expect(() => { - observer.observe(true); - }).toThrowError(/Element/i); + expect(() => { + observer.observe(); + }).toThrowError(/1 argument required/i); + }); - expect(() => { - observer.observe(null); - }).toThrowError(/Element/i); + it('throws an error if target is not an Element', () => { + observer = new ResizeObserver(emptyFn); - expect(() => { - observer.observe({}); - }).toThrowError(/Element/i); + expect(() => { + observer.observe(true); + }).toThrowError(/Element/i); - expect(() => { - observer.observe(document.createTextNode('')); - }).toThrowError(/Element/i); - }); + expect(() => { + observer.observe(null); + }).toThrowError(/Element/i); - it('triggers when observation begins', done => { - observer = new ResizeObserver(done); + expect(() => { + observer.observe({}); + }).toThrowError(/Element/i); - observer.observe(elements.target1); + expect(() => { + observer.observe(document.createTextNode('')); + }).toThrowError(/Element/i); + }); }); - it('triggers with correct arguments', done => { - observer = new ResizeObserver(function (...args) { - const [entries, instance] = args; + describe('unobserve', () => { + it('throws an error if no arguments have been provided', () => { + observer = new ResizeObserver(emptyFn); - expect(args.length).toEqual(2); - - expect(Array.isArray(entries)).toBe(true); - expect(entries.length).toEqual(1); + expect(() => { + observer.unobserve(); + }).toThrowError(/1 argument required/i); + }); - expect(entries[0] instanceof ResizeObserverEntry).toBe(true); + it('throws an error if target is not an Element', () => { + observer = new ResizeObserver(emptyFn); - expect(entries[0].target).toBe(elements.target1); - expect(typeof entries[0].contentRect).toBe('object'); + expect(() => { + observer.unobserve(true); + }).toThrowError(/Element/i); - expect(instance).toBe(observer); + expect(() => { + observer.unobserve(null); + }).toThrowError(/Element/i); - // eslint-disable-next-line no-invalid-this - expect(this).toBe(observer); + expect(() => { + observer.unobserve({}); + }).toThrowError(/Element/i); - done(); + expect(() => { + observer.unobserve(document.createTextNode('')); + }).toThrowError(/Element/i); }); - - observer.observe(elements.target1); }); + }); - it('preserves the initial order of elements', done => { - const spy = createAsyncSpy(); - - observer = new ResizeObserver(spy); - - observer.observe(elements.target2); - observer.observe(elements.target1); - - spy.nextCall().then(entries => { - expect(entries.length).toBe(2); - - expect(entries[0].target).toBe(elements.target2); - expect(entries[1].target).toBe(elements.target1); - }).then(async () => { - elements.target1.style.height = '400px'; - elements.target2.style.height = '100px'; + function specForAnyRoot() { + describe('observe', () => { + it('triggers when observation begins', done => { + observer = new ResizeObserver(done); - const entries = await spy.nextCall(); + observer.observe(elements.target1); + }); - expect(entries.length).toBe(2); + it('triggers with correct arguments', done => { + observer = new ResizeObserver(function (...args) { + const [entries, instance] = args; - expect(entries[0].target).toBe(elements.target2); - expect(entries[1].target).toBe(elements.target1); - }).then(done).catch(done.fail); - }); + expect(args.length).toEqual(2); - // Checks that gathering of active observations and broadcasting of - // notifications happens in separate cycles. - it('doesn\'t block notifications when multiple observers are used', done => { - const spy = createAsyncSpy(); - const spy2 = createAsyncSpy(); + expect(Array.isArray(entries)).toBe(true); + expect(entries.length).toEqual(1); - const defaultWidth = getComputedStyle(elements.target1).width; + expect(entries[0] instanceof ResizeObserverEntry).toBe(true); - let shouldRestoreDefault = false; + expect(entries[0].target).toBe(elements.target1); + expect(typeof entries[0].contentRect).toBe('object'); - observer = new ResizeObserver((...args) => { - spy(...args); + expect(instance).toBe(observer); - if (shouldRestoreDefault) { - elements.target1.style.width = defaultWidth; - } - }); + // eslint-disable-next-line no-invalid-this + expect(this).toBe(observer); - observer2 = new ResizeObserver((...args) => { - spy2(...args); + done(); + }); - if (shouldRestoreDefault) { - elements.target1.style.width = defaultWidth; - } + observer.observe(elements.target1); }); - observer.observe(elements.target1); - observer2.observe(elements.target1); + it('preserves the initial order of elements', done => { + const spy = createAsyncSpy(); - Promise.all([ - spy.nextCall(), - spy2.nextCall() - ]).then(() => { - shouldRestoreDefault = true; + observer = new ResizeObserver(spy); - elements.target1.style.width = '220px'; + observer.observe(elements.target2); + observer.observe(elements.target1); - return Promise.all([ - spy.nextCall().then(spy.nextCall), - spy2.nextCall().then(spy2.nextCall) - ]); - }).then(done).catch(done.fail); - }); + spy.nextCall().then(entries => { + expect(entries.length).toBe(2); - it('doesn\'t notify of already observed elements', done => { - const spy = createAsyncSpy(); + expect(entries[0].target).toBe(elements.target2); + expect(entries[1].target).toBe(elements.target1); + }).then(async () => { + elements.target1.style.height = '400px'; + elements.target2.style.height = '100px'; - observer = new ResizeObserver(spy); + const entries = await spy.nextCall(); - observer.observe(elements.target1); + expect(entries.length).toBe(2); - spy.nextCall().then(entries => { - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target1); - }).then(async () => { - observer.observe(elements.target1); + expect(entries[0].target).toBe(elements.target2); + expect(entries[1].target).toBe(elements.target1); + }).then(done).catch(done.fail); + }); - await wait(timeout); + // Checks that gathering of active observations and broadcasting of + // notifications happens in separate cycles. + it('doesn\'t block notifications when multiple observers are used', done => { + const spy = createAsyncSpy(); + const spy2 = createAsyncSpy(); - expect(spy).toHaveBeenCalledTimes(1); + const defaultWidth = getComputedStyle(elements.target1).width; - elements.target1.style.width = '220px'; + let shouldRestoreDefault = false; - const entries = await spy.nextCall(); + observer = new ResizeObserver((...args) => { + spy(...args); - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target1); - }).then(done).catch(done.fail); - }); + if (shouldRestoreDefault) { + elements.target1.style.width = defaultWidth; + } + }); - it('handles elements that are not yet in the DOM', done => { - elements.root.removeChild(elements.container); - elements.container.removeChild(elements.target1); + observer2 = new ResizeObserver((...args) => { + spy2(...args); - const spy = createAsyncSpy(); + if (shouldRestoreDefault) { + elements.target1.style.width = defaultWidth; + } + }); - observer = new ResizeObserver(spy); + observer.observe(elements.target1); + observer2.observe(elements.target1); - observer.observe(elements.target1); + Promise.all([ + spy.nextCall(), + spy2.nextCall() + ]).then(() => { + shouldRestoreDefault = true; - wait(timeout).then(() => { - expect(spy).not.toHaveBeenCalled(); - }).then(async () => { - elements.container.appendChild(elements.target1); + elements.target1.style.width = '220px'; - await wait(timeout); + return Promise.all([ + spy.nextCall().then(spy.nextCall), + spy2.nextCall().then(spy2.nextCall) + ]); + }).then(done).catch(done.fail); + }); - expect(spy).not.toHaveBeenCalled(); - }).then(async () => { - elements.root.appendChild(elements.container); + it('doesn\'t notify of already observed elements', done => { + const spy = createAsyncSpy(); - const entries = await spy.nextCall(); + observer = new ResizeObserver(spy); - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target1); + observer.observe(elements.target1); - expect(entries[0].contentRect.width).toBe(200); - expect(entries[0].contentRect.height).toBe(200); - }).then(done).catch(done.fail); - }); + spy.nextCall().then(entries => { + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); + }).then(async () => { + observer.observe(elements.target1); - it('triggers when an element is removed from DOM', done => { - const spy = createAsyncSpy(); + await wait(timeout); - observer = new ResizeObserver(spy); + expect(spy).toHaveBeenCalledTimes(1); - observer.observe(elements.target1); - observer.observe(elements.target2); + elements.target1.style.width = '220px'; - spy.nextCall().then(entries => { - expect(spy).toHaveBeenCalledTimes(1); + const entries = await spy.nextCall(); - expect(entries.length).toBe(2); + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); + }).then(done).catch(done.fail); + }); - expect(entries[0].target).toBe(elements.target1); - expect(entries[1].target).toBe(elements.target2); - }).then(async () => { + it('handles elements that are not yet in the DOM', done => { + elements.root.removeChild(elements.container); elements.container.removeChild(elements.target1); - const entries = await spy.nextCall(); + const spy = createAsyncSpy(); - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target1); + observer = new ResizeObserver(spy); - expect(entries[0].contentRect.width).toBe(0); - expect(entries[0].contentRect.height).toBe(0); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(0); - expect(entries[0].contentRect.bottom).toBe(0); - expect(entries[0].contentRect.left).toBe(0); - }).then(async () => { - elements.root.removeChild(elements.container); + observer.observe(elements.target1); - const entries = await spy.nextCall(); + wait(timeout).then(() => { + expect(spy).not.toHaveBeenCalled(); + }).then(async () => { + elements.container.appendChild(elements.target1); - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target2); + await wait(timeout); - expect(entries[0].contentRect.width).toBe(0); - expect(entries[0].contentRect.height).toBe(0); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(0); - expect(entries[0].contentRect.bottom).toBe(0); - expect(entries[0].contentRect.left).toBe(0); - }).then(done).catch(done.fail); - }); + expect(spy).not.toHaveBeenCalled(); + }).then(async () => { + elements.root.appendChild(elements.container); - it('handles resizing of the documentElement', done => { - const spy = createAsyncSpy(); - const docElement = document.documentElement; - const styles = window.getComputedStyle(docElement); + const entries = await spy.nextCall(); - observer = new ResizeObserver(spy); + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); - observer.observe(document.documentElement); + expect(entries[0].contentRect.width).toBe(200); + expect(entries[0].contentRect.height).toBe(200); + }).then(done).catch(done.fail); + }); - spy.nextCall().then(entries => { - const width = parseFloat(styles.width); - const height = parseFloat(styles.height); + it('triggers when an element is removed from DOM', done => { + const spy = createAsyncSpy(); - expect(entries.length).toBe(1); + observer = new ResizeObserver(spy); - expect(entries[0].target).toBe(docElement); + observer.observe(elements.target1); + observer.observe(elements.target2); - expect(entries[0].contentRect.width).toBe(width); - expect(entries[0].contentRect.height).toBe(height); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(width); - expect(entries[0].contentRect.bottom).toBe(height); - expect(entries[0].contentRect.left).toBe(0); - }).then(async () => { - document.body.removeChild(elements.root); + spy.nextCall().then(entries => { + expect(spy).toHaveBeenCalledTimes(1); - const width = parseFloat(styles.width); - const height = parseFloat(styles.height); + expect(entries.length).toBe(2); - const entries = await spy.nextCall(); + expect(entries[0].target).toBe(elements.target1); + expect(entries[1].target).toBe(elements.target2); + }).then(async () => { + elements.container.removeChild(elements.target1); - expect(entries.length).toBe(1); + const entries = await spy.nextCall(); - expect(entries[0].target).toBe(docElement); + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); - expect(entries[0].contentRect.width).toBe(width); - expect(entries[0].contentRect.height).toBe(height); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(width); - expect(entries[0].contentRect.bottom).toBe(height); - expect(entries[0].contentRect.left).toBe(0); - }).then(done).catch(done.fail); - }); + expect(entries[0].contentRect.width).toBe(0); + expect(entries[0].contentRect.height).toBe(0); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(0); + expect(entries[0].contentRect.bottom).toBe(0); + expect(entries[0].contentRect.left).toBe(0); + }).then(async () => { + elements.root.removeChild(elements.container); - it('handles hidden elements', done => { - const spy = createAsyncSpy(); + const entries = await spy.nextCall(); - observer = new ResizeObserver(spy); + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target2); - elements.root.style.display = 'none'; - elements.target1.style.display = 'none'; + expect(entries[0].contentRect.width).toBe(0); + expect(entries[0].contentRect.height).toBe(0); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(0); + expect(entries[0].contentRect.bottom).toBe(0); + expect(entries[0].contentRect.left).toBe(0); + }).then(done).catch(done.fail); + }); - observer.observe(elements.target1); + it('handles resizing of the documentElement', done => { + // DOCUMENT_NODE = 9 + if (elements.documentOrShadow.nodeType !== 9) { + // Skip the test. DocumentElement is only relevant for + // document roots. + done(); - wait(timeout).then(() => { - expect(spy).not.toHaveBeenCalled(); - }).then(async () => { - elements.target1.style.display = 'block'; + return; + } - await wait(timeout); + const spy = createAsyncSpy(); + const document = elements.documentOrShadow; + const docElement = document.documentElement; + const styles = window.getComputedStyle(docElement); - expect(spy).not.toHaveBeenCalled(); - }).then(async () => { - elements.root.style.display = 'block'; - elements.target1.style.position = 'fixed'; + observer = new ResizeObserver(spy); - const entries = await spy.nextCall(); + observer.observe(document.documentElement); - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target1); + spy.nextCall().then(entries => { + const width = parseFloat(styles.width); + const height = parseFloat(styles.height); - expect(entries[0].contentRect.width).toBe(200); - expect(entries[0].contentRect.height).toBe(200); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(200); - expect(entries[0].contentRect.bottom).toBe(200); - expect(entries[0].contentRect.left).toBe(0); - }).then(async () => { - elements.root.style.display = 'none'; - elements.target1.style.padding = '10px'; + expect(entries.length).toBe(1); - const entries = await spy.nextCall(); + expect(entries[0].target).toBe(docElement); - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target1); + expect(entries[0].contentRect.width).toBe(width); + expect(entries[0].contentRect.height).toBe(height); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(width); + expect(entries[0].contentRect.bottom).toBe(height); + expect(entries[0].contentRect.left).toBe(0); + }).then(async () => { + document.body.removeChild(elements.root); - expect(entries[0].contentRect.width).toBe(0); - expect(entries[0].contentRect.height).toBe(0); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(0); - expect(entries[0].contentRect.bottom).toBe(0); - expect(entries[0].contentRect.left).toBe(0); - }).then(done).catch(done.fail); - }); + const width = parseFloat(styles.width); + const height = parseFloat(styles.height); - it('handles empty elements', done => { - const spy = createAsyncSpy(); + const entries = await spy.nextCall(); - elements.target1.style.width = '0px'; - elements.target1.style.height = '0px'; - elements.target1.style.padding = '10px'; + expect(entries.length).toBe(1); - observer = new ResizeObserver(spy); + expect(entries[0].target).toBe(docElement); - observer.observe(elements.target1); - observer.observe(elements.target2); + expect(entries[0].contentRect.width).toBe(width); + expect(entries[0].contentRect.height).toBe(height); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(width); + expect(entries[0].contentRect.bottom).toBe(height); + expect(entries[0].contentRect.left).toBe(0); + }).then(done).catch(done.fail); + }); - spy.nextCall().then(entries => { - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target2); + it('handles shadow host resizing', done => { + const {host} = elements.documentOrShadow; - expect(entries[0].contentRect.width).toBe(200); - expect(entries[0].contentRect.height).toBe(200); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(200); - expect(entries[0].contentRect.bottom).toBe(200); - expect(entries[0].contentRect.left).toBe(0); - }).then(async () => { - elements.target1.style.width = '200px'; - elements.target1.style.height = '200px'; + if (!host) { + // Skip the test. Host is only relevant for shadow roots. + done(); - elements.target2.style.width = '0px'; - elements.target2.style.height = '0px'; - elements.target2.padding = '10px'; + return; + } - const entries = await spy.nextCall(); + host.style.width = '301px'; + elements.root.style.width = '100%'; + elements.target1.style.width = '100%'; + elements.root.appendChild(elements.target1); - expect(entries.length).toBe(2); + const spy = createAsyncSpy(); - expect(entries[0].target).toBe(elements.target1); - expect(entries[1].target).toBe(elements.target2); + observer = new ResizeObserver(spy); - expect(entries[0].contentRect.width).toBe(200); - expect(entries[0].contentRect.height).toBe(200); + observer.observe(elements.target1); - expect(entries[1].contentRect.width).toEqual(0); - expect(entries[1].contentRect.height).toBe(0); - expect(entries[1].contentRect.top).toBe(0); - expect(entries[1].contentRect.right).toBe(0); - expect(entries[1].contentRect.bottom).toBe(0); - expect(entries[1].contentRect.left).toBe(0); - }).then(done).catch(done.fail); - }); + spy.nextCall().then(entries => { + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); + expect(entries[0].contentRect.width).toBe(301); + }).then(async () => { + // Wait for the refresh cascade to complete. + await wait(timeout); + }).then(async () => { + host.style.width = '401px'; - it('handles paddings', done => { - const spy = createAsyncSpy(); + const entries = await spy.nextCall(); - elements.target1.style.padding = '2px 4px 6px 8px'; + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); + expect(entries[0].contentRect.width).toBe(401); + }).then(done).catch(done.fail); + }); - observer = new ResizeObserver(spy); + it('handles hidden elements', done => { + const spy = createAsyncSpy(); - observer.observe(elements.target1); + observer = new ResizeObserver(spy); - spy.nextCall().then(entries => { - expect(entries.length).toBe(1); + elements.root.style.display = 'none'; + elements.target1.style.display = 'none'; - expect(entries[0].target).toBe(elements.target1); + observer.observe(elements.target1); - expect(entries[0].contentRect.width).toBe(200); - expect(entries[0].contentRect.height).toBe(200); - expect(entries[0].contentRect.top).toBe(2); - expect(entries[0].contentRect.right).toBe(208); - expect(entries[0].contentRect.bottom).toBe(202); - expect(entries[0].contentRect.left).toBe(8); - }).then(async () => { - elements.target1.style.padding = '3px 6px'; + wait(timeout).then(() => { + expect(spy).not.toHaveBeenCalled(); + }).then(async () => { + elements.target1.style.display = 'block'; - await wait(timeout); + await wait(timeout); - expect(spy).toHaveBeenCalledTimes(1); - }).then(async () => { - elements.target1.style.boxSizing = 'border-box'; + expect(spy).not.toHaveBeenCalled(); + }).then(async () => { + elements.root.style.display = 'block'; + elements.target1.style.position = 'fixed'; - const entries = await spy.nextCall(); + const entries = await spy.nextCall(); - expect(entries.length).toBe(1); + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); - expect(entries[0].target).toBe(elements.target1); + expect(entries[0].contentRect.width).toBe(200); + expect(entries[0].contentRect.height).toBe(200); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(200); + expect(entries[0].contentRect.bottom).toBe(200); + expect(entries[0].contentRect.left).toBe(0); + }).then(async () => { + elements.root.style.display = 'none'; + elements.target1.style.padding = '10px'; - expect(entries[0].contentRect.width).toBe(188); - expect(entries[0].contentRect.height).toBe(194); - expect(entries[0].contentRect.top).toBe(3); - expect(entries[0].contentRect.right).toBe(194); - expect(entries[0].contentRect.bottom).toBe(197); - expect(entries[0].contentRect.left).toBe(6); - }).then(async () => { - elements.target1.style.padding = '0px 6px'; + const entries = await spy.nextCall(); - const entries = await spy.nextCall(); + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); - expect(spy).toHaveBeenCalledTimes(3); + expect(entries[0].contentRect.width).toBe(0); + expect(entries[0].contentRect.height).toBe(0); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(0); + expect(entries[0].contentRect.bottom).toBe(0); + expect(entries[0].contentRect.left).toBe(0); + }).then(done).catch(done.fail); + }); - expect(entries.length).toBe(1); + it('handles empty elements', done => { + const spy = createAsyncSpy(); - expect(entries[0].target).toBe(elements.target1); + elements.target1.style.width = '0px'; + elements.target1.style.height = '0px'; + elements.target1.style.padding = '10px'; - expect(entries[0].contentRect.width).toBe(188); - expect(entries[0].contentRect.height).toBe(200); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(194); - expect(entries[0].contentRect.bottom).toBe(200); - expect(entries[0].contentRect.left).toBe(6); - }).then(async () => { - elements.target1.style.padding = '0px'; + observer = new ResizeObserver(spy); - const entries = await spy.nextCall(); + observer.observe(elements.target1); + observer.observe(elements.target2); - expect(entries.length).toBe(1); + spy.nextCall().then(entries => { + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target2); - expect(entries[0].target).toBe(elements.target1); + expect(entries[0].contentRect.width).toBe(200); + expect(entries[0].contentRect.height).toBe(200); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(200); + expect(entries[0].contentRect.bottom).toBe(200); + expect(entries[0].contentRect.left).toBe(0); + }).then(async () => { + elements.target1.style.width = '200px'; + elements.target1.style.height = '200px'; - expect(entries[0].contentRect.width).toBe(200); - expect(entries[0].contentRect.height).toBe(200); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(200); - expect(entries[0].contentRect.bottom).toBe(200); - expect(entries[0].contentRect.left).toBe(0); - }).then(done).catch(done.fail); - }); + elements.target2.style.width = '0px'; + elements.target2.style.height = '0px'; + elements.target2.padding = '10px'; - it('handles borders', done => { - const spy = createAsyncSpy(); + const entries = await spy.nextCall(); - elements.target1.style.border = '10px solid black'; + expect(entries.length).toBe(2); - observer = new ResizeObserver(spy); + expect(entries[0].target).toBe(elements.target1); + expect(entries[1].target).toBe(elements.target2); - observer.observe(elements.target1); + expect(entries[0].contentRect.width).toBe(200); + expect(entries[0].contentRect.height).toBe(200); - spy.nextCall().then(entries => { - expect(entries.length).toBe(1); + expect(entries[1].contentRect.width).toEqual(0); + expect(entries[1].contentRect.height).toBe(0); + expect(entries[1].contentRect.top).toBe(0); + expect(entries[1].contentRect.right).toBe(0); + expect(entries[1].contentRect.bottom).toBe(0); + expect(entries[1].contentRect.left).toBe(0); + }).then(done).catch(done.fail); + }); - expect(entries[0].target).toBe(elements.target1); + it('handles paddings', done => { + const spy = createAsyncSpy(); - expect(entries[0].contentRect.width).toBe(200); - expect(entries[0].contentRect.height).toBe(200); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(200); - expect(entries[0].contentRect.bottom).toBe(200); - expect(entries[0].contentRect.left).toBe(0); - }).then(async () => { - elements.target1.style.border = '5px solid black'; + elements.target1.style.padding = '2px 4px 6px 8px'; - await wait(timeout); + observer = new ResizeObserver(spy); - expect(spy).toHaveBeenCalledTimes(1); - }).then(async () => { - elements.target1.style.boxSizing = 'border-box'; + observer.observe(elements.target1); - const entries = await spy.nextCall(); + spy.nextCall().then(entries => { + expect(entries.length).toBe(1); - expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); - expect(entries[0].target).toBe(elements.target1); + expect(entries[0].contentRect.width).toBe(200); + expect(entries[0].contentRect.height).toBe(200); + expect(entries[0].contentRect.top).toBe(2); + expect(entries[0].contentRect.right).toBe(208); + expect(entries[0].contentRect.bottom).toBe(202); + expect(entries[0].contentRect.left).toBe(8); + }).then(async () => { + elements.target1.style.padding = '3px 6px'; - expect(entries[0].contentRect.width).toBe(190); - expect(entries[0].contentRect.height).toBe(190); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(190); - expect(entries[0].contentRect.bottom).toBe(190); - expect(entries[0].contentRect.left).toBe(0); - }).then(async () => { - elements.target1.style.borderTop = ''; - elements.target1.style.borderBottom = ''; + await wait(timeout); - const entries = await spy.nextCall(); + expect(spy).toHaveBeenCalledTimes(1); + }).then(async () => { + elements.target1.style.boxSizing = 'border-box'; - expect(entries.length).toBe(1); + const entries = await spy.nextCall(); - expect(entries[0].target).toBe(elements.target1); + expect(entries.length).toBe(1); - expect(entries[0].contentRect.width).toBe(190); - expect(entries[0].contentRect.height).toBe(200); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(190); - expect(entries[0].contentRect.bottom).toBe(200); - expect(entries[0].contentRect.left).toBe(0); - }).then(async () => { - elements.target1.style.borderLeft = ''; - elements.target1.style.borderRight = ''; + expect(entries[0].target).toBe(elements.target1); - const entries = await spy.nextCall(); + expect(entries[0].contentRect.width).toBe(188); + expect(entries[0].contentRect.height).toBe(194); + expect(entries[0].contentRect.top).toBe(3); + expect(entries[0].contentRect.right).toBe(194); + expect(entries[0].contentRect.bottom).toBe(197); + expect(entries[0].contentRect.left).toBe(6); + }).then(async () => { + elements.target1.style.padding = '0px 6px'; - expect(entries.length).toBe(1); + const entries = await spy.nextCall(); - expect(entries[0].target).toBe(elements.target1); + expect(spy).toHaveBeenCalledTimes(3); - expect(entries[0].contentRect.width).toBe(200); - expect(entries[0].contentRect.height).toBe(200); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(200); - expect(entries[0].contentRect.bottom).toBe(200); - expect(entries[0].contentRect.left).toBe(0); - }).then(done).catch(done.fail); - }); + expect(entries.length).toBe(1); - it('doesn\'t notify when position changes', done => { - const spy = createAsyncSpy(); + expect(entries[0].target).toBe(elements.target1); - elements.target1.style.position = 'relative'; - elements.target1.style.top = '7px'; - elements.target1.style.left = '5px;'; - elements.target1.style.padding = '2px 3px'; + expect(entries[0].contentRect.width).toBe(188); + expect(entries[0].contentRect.height).toBe(200); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(194); + expect(entries[0].contentRect.bottom).toBe(200); + expect(entries[0].contentRect.left).toBe(6); + }).then(async () => { + elements.target1.style.padding = '0px'; - observer = new ResizeObserver(spy); + const entries = await spy.nextCall(); - observer.observe(elements.target1); + expect(entries.length).toBe(1); - spy.nextCall().then(entries => { - expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); - expect(entries[0].target).toBe(elements.target1); + expect(entries[0].contentRect.width).toBe(200); + expect(entries[0].contentRect.height).toBe(200); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(200); + expect(entries[0].contentRect.bottom).toBe(200); + expect(entries[0].contentRect.left).toBe(0); + }).then(done).catch(done.fail); + }); - expect(entries[0].contentRect.width).toBe(200); - expect(entries[0].contentRect.height).toBe(200); - expect(entries[0].contentRect.top).toBe(2); - expect(entries[0].contentRect.right).toBe(203); - expect(entries[0].contentRect.bottom).toBe(202); - expect(entries[0].contentRect.left).toBe(3); - }).then(async () => { - elements.target1.style.left = '10px'; - elements.target1.style.top = '20px'; + it('handles borders', done => { + const spy = createAsyncSpy(); - await wait(timeout); + elements.target1.style.border = '10px solid black'; - expect(spy).toHaveBeenCalledTimes(1); - }).then(done).catch(done.fail); - }); + observer = new ResizeObserver(spy); - it('ignores scroll bars size', done => { - const spy = createAsyncSpy(); + observer.observe(elements.target1); - observer = new ResizeObserver(spy); + spy.nextCall().then(entries => { + expect(entries.length).toBe(1); - elements.root.style.width = '100px'; - elements.root.style.height = '250px'; - elements.root.style.overflow = 'auto'; + expect(entries[0].target).toBe(elements.target1); - elements.container.style.minWidth = '0px'; + expect(entries[0].contentRect.width).toBe(200); + expect(entries[0].contentRect.height).toBe(200); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(200); + expect(entries[0].contentRect.bottom).toBe(200); + expect(entries[0].contentRect.left).toBe(0); + }).then(async () => { + elements.target1.style.border = '5px solid black'; - observer.observe(elements.root); + await wait(timeout); - spy.nextCall().then(entries => { - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.root); + expect(spy).toHaveBeenCalledTimes(1); + }).then(async () => { + elements.target1.style.boxSizing = 'border-box'; - expect(entries[0].contentRect.width).toBe(elements.root.clientWidth); - expect(entries[0].contentRect.height).toBe(elements.root.clientHeight); + const entries = await spy.nextCall(); - // It is not possible to run further tests if browser has overlaid scroll bars. - if ( - elements.root.clientWidth === elements.root.offsetWidth && - elements.root.clientHeight === elements.root.offsetHeight - ) { - return Promise.resolve(); - } + expect(entries.length).toBe(1); - return (async () => { - const width = elements.root.clientWidth; + expect(entries[0].target).toBe(elements.target1); - elements.target1.style.width = width + 'px'; - elements.target2.style.width = width + 'px'; + expect(entries[0].contentRect.width).toBe(190); + expect(entries[0].contentRect.height).toBe(190); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(190); + expect(entries[0].contentRect.bottom).toBe(190); + expect(entries[0].contentRect.left).toBe(0); + }).then(async () => { + elements.target1.style.borderTop = ''; + elements.target1.style.borderBottom = ''; const entries = await spy.nextCall(); expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.root); - expect(entries[0].contentRect.height).toBe(250); - })().then(async () => { - elements.target1.style.height = '125px'; - elements.target2.style.height = '125px'; + expect(entries[0].target).toBe(elements.target1); + + expect(entries[0].contentRect.width).toBe(190); + expect(entries[0].contentRect.height).toBe(200); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(190); + expect(entries[0].contentRect.bottom).toBe(200); + expect(entries[0].contentRect.left).toBe(0); + }).then(async () => { + elements.target1.style.borderLeft = ''; + elements.target1.style.borderRight = ''; const entries = await spy.nextCall(); expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.root); - expect(entries[0].contentRect.width).toBe(100); - }); - }).then(done).catch(done.fail); - }); + expect(entries[0].target).toBe(elements.target1); - it('doesn\'t trigger for a non-replaced inline elements', done => { - const spy = createAsyncSpy(); + expect(entries[0].contentRect.width).toBe(200); + expect(entries[0].contentRect.height).toBe(200); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(200); + expect(entries[0].contentRect.bottom).toBe(200); + expect(entries[0].contentRect.left).toBe(0); + }).then(done).catch(done.fail); + }); - observer = new ResizeObserver(spy); + it('doesn\'t notify when position changes', done => { + const spy = createAsyncSpy(); - elements.target1.style.display = 'inline'; - elements.target1.style.padding = '10px'; + elements.target1.style.position = 'relative'; + elements.target1.style.top = '7px'; + elements.target1.style.left = '5px;'; + elements.target1.style.padding = '2px 3px'; - observer.observe(elements.target1); + observer = new ResizeObserver(spy); - wait(timeout).then(() => { - expect(spy).not.toHaveBeenCalled(); - }).then(async () => { - elements.target1.style.position = 'absolute'; + observer.observe(elements.target1); - const entries = await spy.nextCall(); + spy.nextCall().then(entries => { + expect(entries.length).toBe(1); + + expect(entries[0].target).toBe(elements.target1); + + expect(entries[0].contentRect.width).toBe(200); + expect(entries[0].contentRect.height).toBe(200); + expect(entries[0].contentRect.top).toBe(2); + expect(entries[0].contentRect.right).toBe(203); + expect(entries[0].contentRect.bottom).toBe(202); + expect(entries[0].contentRect.left).toBe(3); + }).then(async () => { + elements.target1.style.left = '10px'; + elements.target1.style.top = '20px'; + + await wait(timeout); + + expect(spy).toHaveBeenCalledTimes(1); + }).then(done).catch(done.fail); + }); + + it('ignores scroll bars size', done => { + const spy = createAsyncSpy(); + + observer = new ResizeObserver(spy); - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target1); + elements.root.style.width = '100px'; + elements.root.style.height = '250px'; + elements.root.style.overflow = 'auto'; - expect(entries[0].contentRect.width).toBe(200); - expect(entries[0].contentRect.height).toBe(200); - expect(entries[0].contentRect.top).toBe(10); - expect(entries[0].contentRect.left).toBe(10); - }).then(async () => { - elements.target1.style.position = 'static'; + elements.container.style.minWidth = '0px'; - const entries = await spy.nextCall(); + observer.observe(elements.root); - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target1); + spy.nextCall().then(entries => { + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.root); + + expect(entries[0].contentRect.width).toBe(elements.root.clientWidth); + expect(entries[0].contentRect.height).toBe(elements.root.clientHeight); + + // It is not possible to run further tests if browser has overlaid scroll bars. + if ( + elements.root.clientWidth === elements.root.offsetWidth && + elements.root.clientHeight === elements.root.offsetHeight + ) { + return Promise.resolve(); + } + + return (async () => { + const width = elements.root.clientWidth; + + elements.target1.style.width = width + 'px'; + elements.target2.style.width = width + 'px'; + + const entries = await spy.nextCall(); + + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.root); - expect(entries[0].contentRect.width).toBe(0); - expect(entries[0].contentRect.height).toBe(0); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(0); - expect(entries[0].contentRect.bottom).toBe(0); - expect(entries[0].contentRect.left).toBe(0); - }).then(async () => { - elements.target1.style.width = '150px'; + expect(entries[0].contentRect.height).toBe(250); + })().then(async () => { + elements.target1.style.height = '125px'; + elements.target2.style.height = '125px'; - await wait(timeout); + const entries = await spy.nextCall(); - expect(spy).toHaveBeenCalledTimes(2); - }).then(async () => { - elements.target1.style.display = 'block'; + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.root); - const entries = await spy.nextCall(); + expect(entries[0].contentRect.width).toBe(100); + }); + }).then(done).catch(done.fail); + }); - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target1); + it('doesn\'t trigger for a non-replaced inline elements', done => { + const spy = createAsyncSpy(); + + observer = new ResizeObserver(spy); - expect(entries[0].contentRect.width).toBe(150); - expect(entries[0].contentRect.height).toBe(200); - expect(entries[0].contentRect.top).toBe(10); - expect(entries[0].contentRect.left).toBe(10); - }).then(async () => { elements.target1.style.display = 'inline'; + elements.target1.style.padding = '10px'; - const entries = await spy.nextCall(); + observer.observe(elements.target1); - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target1); + wait(timeout).then(() => { + expect(spy).not.toHaveBeenCalled(); + }).then(async () => { + elements.target1.style.position = 'absolute'; - expect(entries[0].contentRect.width).toBe(0); - expect(entries[0].contentRect.height).toBe(0); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(0); - expect(entries[0].contentRect.bottom).toBe(0); - expect(entries[0].contentRect.left).toBe(0); - }).then(done).catch(done.fail); - }); + const entries = await spy.nextCall(); - it('handles replaced inline elements', done => { - elements.root.insertAdjacentHTML('beforeend', ` - - ` - ); - - const spy = createAsyncSpy(); - const replaced = document.getElementById('replaced-inline'); - - observer = new ResizeObserver(spy); - - observer.observe(replaced); - - spy.nextCall().then(entries => { - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(replaced); - - expect(entries[0].contentRect.width).toBe(200); - expect(entries[0].contentRect.height).toBe(30); - expect(entries[0].contentRect.top).toBe(5); - expect(entries[0].contentRect.right).toBe(206); - expect(entries[0].contentRect.bottom).toBe(35); - expect(entries[0].contentRect.left).toBe(6); - }).then(async () => { - replaced.style.width = '190px'; - - const entries = await spy.nextCall(); - - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(replaced); - - expect(entries[0].contentRect.width).toBe(190); - expect(entries[0].contentRect.height).toBe(30); - expect(entries[0].contentRect.top).toBe(5); - expect(entries[0].contentRect.right).toBe(196); - expect(entries[0].contentRect.bottom).toBe(35); - expect(entries[0].contentRect.left).toBe(6); - }).then(async () => { - replaced.style.boxSizing = 'border-box'; - - const entries = await spy.nextCall(); - - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(replaced); - - expect(entries[0].contentRect.width).toBe(174); - expect(entries[0].contentRect.height).toBe(16); - expect(entries[0].contentRect.top).toBe(5); - expect(entries[0].contentRect.right).toBe(180); - expect(entries[0].contentRect.bottom).toBe(21); - expect(entries[0].contentRect.left).toBe(6); - }).then(done).catch(done.fail); - }); + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); - it('handles fractional dimensions', done => { - elements.target1.style.width = '200.5px'; - elements.target1.style.height = '200.5px'; - elements.target1.style.padding = '6.3px 3.3px'; - elements.target1.style.border = '11px solid black'; - - const spy = createAsyncSpy(); - - observer = new ResizeObserver(spy); - - observer.observe(elements.target1); - - spy.nextCall().then(entries => { - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target1); - - expect(entries[0].contentRect.width).toBeCloseTo(200.5, 1); - expect(entries[0].contentRect.height).toBeCloseTo(200.5, 1); - expect(entries[0].contentRect.top).toBeCloseTo(6.3, 1); - expect(entries[0].contentRect.right).toBeCloseTo(203.8, 1); - expect(entries[0].contentRect.bottom).toBeCloseTo(206.8, 1); - expect(entries[0].contentRect.left).toBeCloseTo(3.3, 1); - }).then(async () => { - elements.target1.style.padding = '7.8px 3.8px'; - - await wait(timeout); - - expect(spy).toHaveBeenCalledTimes(1); - }).then(async () => { - elements.target1.style.boxSizing = 'border-box'; - - const entries = await spy.nextCall(); - - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target1); - - expect(entries[0].contentRect.width).toBeCloseTo(170.9, 1); - expect(entries[0].contentRect.height).toBeCloseTo(162.9, 1); - expect(entries[0].contentRect.top).toBeCloseTo(7.8, 1); - expect(entries[0].contentRect.right).toBeCloseTo(174.7, 1); - expect(entries[0].contentRect.bottom).toBeCloseTo(170.7, 1); - expect(entries[0].contentRect.left).toBeCloseTo(3.8, 1); - }).then(async () => { - elements.target1.style.padding = '7.9px 3.9px'; - - const entries = await spy.nextCall(); - - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target1); - - expect(entries[0].contentRect.width).toBeCloseTo(170.7, 1); - expect(entries[0].contentRect.height).toBeCloseTo(162.7, 1); - expect(entries[0].contentRect.top).toBeCloseTo(7.9, 1); - expect(entries[0].contentRect.right).toBeCloseTo(174.6, 1); - expect(entries[0].contentRect.bottom).toBeCloseTo(170.6, 1); - expect(entries[0].contentRect.left).toBeCloseTo(3.9, 1); - }).then(async () => { - elements.target1.style.width = '200px'; - - const entries = await spy.nextCall(); - - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target1); - - expect(entries[0].contentRect.width).toBeCloseTo(170.2, 1); - expect(entries[0].contentRect.height).toBeCloseTo(162.7, 1); - expect(entries[0].contentRect.top).toBeCloseTo(7.9, 1); - expect(entries[0].contentRect.right).toBeCloseTo(174.1, 1); - expect(entries[0].contentRect.bottom).toBeCloseTo(170.6, 1); - expect(entries[0].contentRect.left).toBeCloseTo(3.9, 1); - }).then(done).catch(done.fail); - }); + expect(entries[0].contentRect.width).toBe(200); + expect(entries[0].contentRect.height).toBe(200); + expect(entries[0].contentRect.top).toBe(10); + expect(entries[0].contentRect.left).toBe(10); + }).then(async () => { + elements.target1.style.position = 'static'; - it('handles SVGGraphicsElement', done => { - elements.root.insertAdjacentHTML('beforeend', ` - - - - `); - - const spy = createAsyncSpy(); - const svgRoot = document.getElementById('svg-root'); - const svgRect = document.getElementById('svg-rect'); - - observer = new ResizeObserver(spy); - - observer.observe(svgRect); - - spy.nextCall().then(entries => { - expect(entries.length).toBe(1); - - expect(entries[0].target).toBe(svgRect); - - expect(entries[0].contentRect.width).toBe(200); - expect(entries[0].contentRect.height).toBe(150); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(200); - expect(entries[0].contentRect.bottom).toBe(150); - expect(entries[0].contentRect.left).toBe(0); - }).then(async () => { - svgRect.setAttribute('width', 250); - svgRect.setAttribute('height', 200); - - const entries = await spy.nextCall(); - - expect(entries.length).toBe(1); - - expect(entries[0].target).toBe(svgRect); - - expect(entries[0].contentRect.width).toBe(250); - expect(entries[0].contentRect.height).toBe(200); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(250); - expect(entries[0].contentRect.bottom).toBe(200); - expect(entries[0].contentRect.left).toBe(0); - }).then(async () => { - observer.observe(svgRoot); - - const entries = await spy.nextCall(); - - expect(entries.length).toBe(1); - - expect(entries[0].target).toBe(svgRoot); - - expect(entries[0].contentRect.width).toBe(250); - expect(entries[0].contentRect.height).toBe(200); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.right).toBe(250); - expect(entries[0].contentRect.bottom).toBe(200); - expect(entries[0].contentRect.left).toBe(0); - }).then(done).catch(done.fail); - }); + const entries = await spy.nextCall(); - it('doesn\'t observe svg elements that don\'t implement the SVGGraphicsElement interface', done => { - elements.root.insertAdjacentHTML('beforeend', ` - - - - - - - + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); - - - `); + expect(entries[0].contentRect.width).toBe(0); + expect(entries[0].contentRect.height).toBe(0); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(0); + expect(entries[0].contentRect.bottom).toBe(0); + expect(entries[0].contentRect.left).toBe(0); + }).then(async () => { + elements.target1.style.width = '150px'; - const spy = createAsyncSpy(); - const svgGrad = document.getElementById('gradient'); - const svgCircle = document.getElementById('circle'); + await wait(timeout); - observer = new ResizeObserver(spy); + expect(spy).toHaveBeenCalledTimes(2); + }).then(async () => { + elements.target1.style.display = 'block'; - observer.observe(svgGrad); + const entries = await spy.nextCall(); - wait(timeout).then(() => { - expect(spy).not.toHaveBeenCalled(); + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); - observer.observe(svgCircle); + expect(entries[0].contentRect.width).toBe(150); + expect(entries[0].contentRect.height).toBe(200); + expect(entries[0].contentRect.top).toBe(10); + expect(entries[0].contentRect.left).toBe(10); + }).then(async () => { + elements.target1.style.display = 'inline'; - return spy.nextCall(); - }).then(entries => { - expect(spy).toHaveBeenCalledTimes(1); + const entries = await spy.nextCall(); - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(svgCircle); + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); - expect(entries[0].contentRect.top).toBe(0); - expect(entries[0].contentRect.left).toBe(0); - expect(entries[0].contentRect.width).toBe(100); - expect(entries[0].contentRect.height).toBe(100); - }).then(done).catch(done.fail); - }); + expect(entries[0].contentRect.width).toBe(0); + expect(entries[0].contentRect.height).toBe(0); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(0); + expect(entries[0].contentRect.bottom).toBe(0); + expect(entries[0].contentRect.left).toBe(0); + }).then(done).catch(done.fail); + }); - it('handles IE11 issue with the MutationObserver: https://jsfiddle.net/x2r3jpuz/2/', done => { - const spy = createAsyncSpy(); + it('handles replaced inline elements', done => { + elements.root.insertAdjacentHTML('beforeend', ` + + ` + ); - elements.root.insertAdjacentHTML('beforeend', ` -

- -

- `); + const spy = createAsyncSpy(); + const replaced = elements.documentOrShadow.getElementById('replaced-inline'); - observer = new ResizeObserver(spy); + observer = new ResizeObserver(spy); - observer.observe(elements.root); + observer.observe(replaced); - spy.nextCall().then(async () => { - const elem = elements.root.querySelector('strong'); + spy.nextCall().then(entries => { + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(replaced); - // IE11 crashes at this step if MuatationObserver is used. - elem.textContent = 'a'; - elem.textContent = 'b'; + expect(entries[0].contentRect.width).toBe(200); + expect(entries[0].contentRect.height).toBe(30); + expect(entries[0].contentRect.top).toBe(5); + expect(entries[0].contentRect.right).toBe(206); + expect(entries[0].contentRect.bottom).toBe(35); + expect(entries[0].contentRect.left).toBe(6); + }).then(async () => { + replaced.style.width = '190px'; - await wait(timeout); - }).then(done).catch(done.fail); - }); + const entries = await spy.nextCall(); + + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(replaced); + + expect(entries[0].contentRect.width).toBe(190); + expect(entries[0].contentRect.height).toBe(30); + expect(entries[0].contentRect.top).toBe(5); + expect(entries[0].contentRect.right).toBe(196); + expect(entries[0].contentRect.bottom).toBe(35); + expect(entries[0].contentRect.left).toBe(6); + }).then(async () => { + replaced.style.boxSizing = 'border-box'; + + const entries = await spy.nextCall(); + + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(replaced); + + expect(entries[0].contentRect.width).toBe(174); + expect(entries[0].contentRect.height).toBe(16); + expect(entries[0].contentRect.top).toBe(5); + expect(entries[0].contentRect.right).toBe(180); + expect(entries[0].contentRect.bottom).toBe(21); + expect(entries[0].contentRect.left).toBe(6); + }).then(done).catch(done.fail); + }); + + it('handles fractional dimensions', done => { + elements.target1.style.width = '200.5px'; + elements.target1.style.height = '200.5px'; + elements.target1.style.padding = '6.3px 3.3px'; + elements.target1.style.border = '11px solid black'; - if (typeof document.body.style.transform !== 'undefined') { - it('doesn\'t notify of transformations', done => { const spy = createAsyncSpy(); observer = new ResizeObserver(spy); @@ -1089,288 +967,545 @@ describe('ResizeObserver', () => { expect(entries.length).toBe(1); expect(entries[0].target).toBe(elements.target1); + expect(entries[0].contentRect.width).toBeCloseTo(200.5, 1); + expect(entries[0].contentRect.height).toBeCloseTo(200.5, 1); + expect(entries[0].contentRect.top).toBeCloseTo(6.3, 1); + expect(entries[0].contentRect.right).toBeCloseTo(203.8, 1); + expect(entries[0].contentRect.bottom).toBeCloseTo(206.8, 1); + expect(entries[0].contentRect.left).toBeCloseTo(3.3, 1); + }).then(async () => { + elements.target1.style.padding = '7.8px 3.8px'; + + await wait(timeout); + + expect(spy).toHaveBeenCalledTimes(1); + }).then(async () => { + elements.target1.style.boxSizing = 'border-box'; + + const entries = await spy.nextCall(); + + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); + + expect(entries[0].contentRect.width).toBeCloseTo(170.9, 1); + expect(entries[0].contentRect.height).toBeCloseTo(162.9, 1); + expect(entries[0].contentRect.top).toBeCloseTo(7.8, 1); + expect(entries[0].contentRect.right).toBeCloseTo(174.7, 1); + expect(entries[0].contentRect.bottom).toBeCloseTo(170.7, 1); + expect(entries[0].contentRect.left).toBeCloseTo(3.8, 1); + }).then(async () => { + elements.target1.style.padding = '7.9px 3.9px'; + + const entries = await spy.nextCall(); + + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); + + expect(entries[0].contentRect.width).toBeCloseTo(170.7, 1); + expect(entries[0].contentRect.height).toBeCloseTo(162.7, 1); + expect(entries[0].contentRect.top).toBeCloseTo(7.9, 1); + expect(entries[0].contentRect.right).toBeCloseTo(174.6, 1); + expect(entries[0].contentRect.bottom).toBeCloseTo(170.6, 1); + expect(entries[0].contentRect.left).toBeCloseTo(3.9, 1); + }).then(async () => { + elements.target1.style.width = '200px'; + + const entries = await spy.nextCall(); + + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); + + expect(entries[0].contentRect.width).toBeCloseTo(170.2, 1); + expect(entries[0].contentRect.height).toBeCloseTo(162.7, 1); + expect(entries[0].contentRect.top).toBeCloseTo(7.9, 1); + expect(entries[0].contentRect.right).toBeCloseTo(174.1, 1); + expect(entries[0].contentRect.bottom).toBeCloseTo(170.6, 1); + expect(entries[0].contentRect.left).toBeCloseTo(3.9, 1); + }).then(done).catch(done.fail); + }); + + it('handles SVGGraphicsElement', done => { + elements.root.insertAdjacentHTML('beforeend', ` + + + + `); + + const spy = createAsyncSpy(); + const svgRoot = elements.documentOrShadow.getElementById('svg-root'); + const svgRect = elements.documentOrShadow.getElementById('svg-rect'); + + observer = new ResizeObserver(spy); + + observer.observe(svgRect); + + spy.nextCall().then(entries => { + expect(entries.length).toBe(1); + + expect(entries[0].target).toBe(svgRect); + expect(entries[0].contentRect.width).toBe(200); - expect(entries[0].contentRect.height).toBe(200); + expect(entries[0].contentRect.height).toBe(150); expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(200); + expect(entries[0].contentRect.bottom).toBe(150); expect(entries[0].contentRect.left).toBe(0); }).then(async () => { - elements.container.style.transform = 'scale(0.5)'; - elements.target2.style.transform = 'scale(0.5)'; - - observer.observe(elements.target2); + svgRect.setAttribute('width', 250); + svgRect.setAttribute('height', 200); const entries = await spy.nextCall(); expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target2); - expect(entries[0].contentRect.width).toBe(200); + expect(entries[0].target).toBe(svgRect); + + expect(entries[0].contentRect.width).toBe(250); expect(entries[0].contentRect.height).toBe(200); expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(250); + expect(entries[0].contentRect.bottom).toBe(200); expect(entries[0].contentRect.left).toBe(0); }).then(async () => { - elements.container.style.transform = ''; - elements.target2.style.transform = ''; + observer.observe(svgRoot); - await wait(timeout); + const entries = await spy.nextCall(); - expect(spy).toHaveBeenCalledTimes(2); + expect(entries.length).toBe(1); + + expect(entries[0].target).toBe(svgRoot); + + expect(entries[0].contentRect.width).toBe(250); + expect(entries[0].contentRect.height).toBe(200); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.right).toBe(250); + expect(entries[0].contentRect.bottom).toBe(200); + expect(entries[0].contentRect.left).toBe(0); }).then(done).catch(done.fail); }); - } - if (typeof document.body.style.transition !== 'undefined') { - it('handles transitions', done => { - elements.target1.style.transition = 'width 1s'; + it('doesn\'t observe svg elements that don\'t implement the SVGGraphicsElement interface', done => { + elements.root.insertAdjacentHTML('beforeend', ` + + + + + + + + + + + `); const spy = createAsyncSpy(); + const svgGrad = elements.documentOrShadow.getElementById('gradient'); + const svgCircle = elements.documentOrShadow.getElementById('circle'); observer = new ResizeObserver(spy); - observer.observe(elements.target1); + observer.observe(svgGrad); - spy.nextCall().then(async () => { - const transitionEnd = new Promise(resolve => { - const callback = () => { - elements.target1.removeEventListener('transitionend', callback); - resolve(); - }; + wait(timeout).then(() => { + expect(spy).not.toHaveBeenCalled(); - elements.target1.addEventListener('transitionend', callback); - }); + observer.observe(svgCircle); - await wait(20); + return spy.nextCall(); + }).then(entries => { + expect(spy).toHaveBeenCalledTimes(1); - elements.target1.style.width = '600px'; + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(svgCircle); - await transitionEnd; - await wait(timeout); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.left).toBe(0); + expect(entries[0].contentRect.width).toBe(100); + expect(entries[0].contentRect.height).toBe(100); + }).then(done).catch(done.fail); + }); - // eslint-disable-next-line prefer-destructuring - const entries = spy.calls.mostRecent().args[0]; + it('handles IE11 issue with the MutationObserver: https://jsfiddle.net/x2r3jpuz/2/', done => { + const spy = createAsyncSpy(); - expect(entries[0].target).toBe(elements.target1); - expect(Math.round(entries[0].contentRect.width)).toBe(600); + elements.root.insertAdjacentHTML('beforeend', ` +

+ +

+ `); + + observer = new ResizeObserver(spy); + + observer.observe(elements.root); + + spy.nextCall().then(async () => { + const elem = elements.root.querySelector('strong'); + + // IE11 crashes at this step if MuatationObserver is used. + elem.textContent = 'a'; + elem.textContent = 'b'; + + await wait(timeout); }).then(done).catch(done.fail); }); - } - }); - describe('unobserve', () => { - it('throws an error if no arguments have been provided', () => { - observer = new ResizeObserver(emptyFn); + if (typeof document.body.style.transform !== 'undefined') { + it('doesn\'t notify of transformations', done => { + const spy = createAsyncSpy(); - expect(() => { - observer.unobserve(); - }).toThrowError(/1 argument required/i); - }); + observer = new ResizeObserver(spy); - it('throws an error if target is not an Element', () => { - observer = new ResizeObserver(emptyFn); + observer.observe(elements.target1); - expect(() => { - observer.unobserve(true); - }).toThrowError(/Element/i); + spy.nextCall().then(entries => { + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target1); - expect(() => { - observer.unobserve(null); - }).toThrowError(/Element/i); + expect(entries[0].contentRect.width).toBe(200); + expect(entries[0].contentRect.height).toBe(200); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.left).toBe(0); + }).then(async () => { + elements.container.style.transform = 'scale(0.5)'; + elements.target2.style.transform = 'scale(0.5)'; - expect(() => { - observer.unobserve({}); - }).toThrowError(/Element/i); + observer.observe(elements.target2); - expect(() => { - observer.unobserve(document.createTextNode('')); - }).toThrowError(/Element/i); - }); + const entries = await spy.nextCall(); - it('stops observing single element', done => { - const spy = createAsyncSpy(); + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target2); - observer = new ResizeObserver(spy); + expect(entries[0].contentRect.width).toBe(200); + expect(entries[0].contentRect.height).toBe(200); + expect(entries[0].contentRect.top).toBe(0); + expect(entries[0].contentRect.left).toBe(0); + }).then(async () => { + elements.container.style.transform = ''; + elements.target2.style.transform = ''; - observer.observe(elements.target1); - observer.observe(elements.target2); + await wait(timeout); - spy.nextCall().then(entries => { - expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(2); + }).then(done).catch(done.fail); + }); + } - expect(entries.length).toBe(2); + if (typeof document.body.style.transition !== 'undefined') { + it('handles transitions', done => { + elements.target1.style.transition = 'width 1s'; - expect(entries[0].target).toBe(elements.target1); - expect(entries[1].target).toBe(elements.target2); - }).then(async () => { - observer.unobserve(elements.target1); + const spy = createAsyncSpy(); + + observer = new ResizeObserver(spy); - elements.target1.style.width = '50px'; - elements.target2.style.width = '50px'; + observer.observe(elements.target1); - const entries = await spy.nextCall(); + spy.nextCall().then(async () => { + const transitionEnd = new Promise(resolve => { + const callback = () => { + elements.target1.removeEventListener('transitionend', callback); + resolve(); + }; - expect(spy).toHaveBeenCalledTimes(2); + elements.target1.addEventListener('transitionend', callback); + }); - expect(entries.length).toBe(1); - expect(entries[0].target).toBe(elements.target2); - expect(entries[0].contentRect.width).toBe(50); - }).then(async () => { - elements.target2.style.width = '100px'; + await wait(20); - observer.unobserve(elements.target2); + elements.target1.style.width = '600px'; - await wait(timeout); + await transitionEnd; + await wait(timeout); - expect(spy).toHaveBeenCalledTimes(2); - }).then(done).catch(done.fail); + // eslint-disable-next-line prefer-destructuring + const entries = spy.calls.mostRecent().args[0]; + + expect(entries[0].target).toBe(elements.target1); + expect(Math.round(entries[0].contentRect.width)).toBe(600); + }).then(done).catch(done.fail); + }); + } }); - it('doesn\'t prevent gathered observations from being notified', done => { - const spy = createAsyncSpy(); - const spy2 = createAsyncSpy(); + describe('unobserve', () => { + it('stops observing single element', done => { + const spy = createAsyncSpy(); - let shouldUnobserve = false; + observer = new ResizeObserver(spy); - observer = new ResizeObserver((...args) => { - spy(...args); + observer.observe(elements.target1); + observer.observe(elements.target2); - if (shouldUnobserve) { - observer2.unobserve(elements.target1); - } - }); + spy.nextCall().then(entries => { + expect(spy).toHaveBeenCalledTimes(1); - observer2 = new ResizeObserver((...args) => { - spy2(...args); + expect(entries.length).toBe(2); - if (shouldUnobserve) { + expect(entries[0].target).toBe(elements.target1); + expect(entries[1].target).toBe(elements.target2); + }).then(async () => { observer.unobserve(elements.target1); - } - }); - observer.observe(elements.target1); - observer2.observe(elements.target1); + elements.target1.style.width = '50px'; + elements.target2.style.width = '50px'; - Promise.all([ - spy.nextCall(), - spy2.nextCall() - ]).then(() => { - shouldUnobserve = true; + const entries = await spy.nextCall(); - elements.target1.style.width = '220px'; + expect(spy).toHaveBeenCalledTimes(2); - return Promise.all([spy.nextCall(), spy2.nextCall()]); - }).then(done).catch(done.fail); - }); + expect(entries.length).toBe(1); + expect(entries[0].target).toBe(elements.target2); + expect(entries[0].contentRect.width).toBe(50); + }).then(async () => { + elements.target2.style.width = '100px'; - it('handles elements that are not observed', done => { - const spy = createAsyncSpy(); + observer.unobserve(elements.target2); - observer = new ResizeObserver(spy); + await wait(timeout); - observer.unobserve(elements.target1); + expect(spy).toHaveBeenCalledTimes(2); + }).then(done).catch(done.fail); + }); - wait(timeout).then(() => { - expect(spy).not.toHaveBeenCalled(); - }).then(done).catch(done.fail); - }); - }); + it('doesn\'t prevent gathered observations from being notified', done => { + const spy = createAsyncSpy(); + const spy2 = createAsyncSpy(); - describe('disconnect', () => { - it('stops observing all elements', done => { - const spy = createAsyncSpy(); + let shouldUnobserve = false; - observer = new ResizeObserver(spy); + observer = new ResizeObserver((...args) => { + spy(...args); - observer.observe(elements.target1); - observer.observe(elements.target2); + if (shouldUnobserve) { + observer2.unobserve(elements.target1); + } + }); - spy.nextCall().then(entries => { - expect(entries.length).toBe(2); + observer2 = new ResizeObserver((...args) => { + spy2(...args); - expect(entries[0].target).toBe(elements.target1); - expect(entries[1].target).toBe(elements.target2); - }).then(async () => { - elements.target1.style.width = '600px'; - elements.target2.style.width = '600px'; + if (shouldUnobserve) { + observer.unobserve(elements.target1); + } + }); - observer.disconnect(); + observer.observe(elements.target1); + observer2.observe(elements.target1); - await wait(timeout); + Promise.all([ + spy.nextCall(), + spy2.nextCall() + ]).then(() => { + shouldUnobserve = true; - expect(spy).toHaveBeenCalledTimes(1); - }).then(done).catch(done.fail); - }); + elements.target1.style.width = '220px'; + + return Promise.all([spy.nextCall(), spy2.nextCall()]); + }).then(done).catch(done.fail); + }); - it('prevents gathered observations from being notified', done => { - const spy = createAsyncSpy(); - const spy2 = createAsyncSpy(); + it('handles elements that are not observed', done => { + const spy = createAsyncSpy(); - let shouldDisconnect = false; + observer = new ResizeObserver(spy); - observer = new ResizeObserver((...args) => { - spy(...args); + observer.unobserve(elements.target1); - if (shouldDisconnect) { - observer2.disconnect(); - } + wait(timeout).then(() => { + expect(spy).not.toHaveBeenCalled(); + }).then(done).catch(done.fail); }); + }); + + describe('disconnect', () => { + it('stops observing all elements', done => { + const spy = createAsyncSpy(); + + observer = new ResizeObserver(spy); + + observer.observe(elements.target1); + observer.observe(elements.target2); + + spy.nextCall().then(entries => { + expect(entries.length).toBe(2); - observer2 = new ResizeObserver((...args) => { - spy2(...args); + expect(entries[0].target).toBe(elements.target1); + expect(entries[1].target).toBe(elements.target2); + }).then(async () => { + elements.target1.style.width = '600px'; + elements.target2.style.width = '600px'; - if (shouldDisconnect) { observer.disconnect(); - } + + await wait(timeout); + + expect(spy).toHaveBeenCalledTimes(1); + }).then(done).catch(done.fail); }); - observer.observe(elements.target1); - observer2.observe(elements.target1); + it('prevents gathered observations from being notified', done => { + const spy = createAsyncSpy(); + const spy2 = createAsyncSpy(); - Promise.all([ - spy.nextCall(), - spy2.nextCall() - ]).then(async () => { - shouldDisconnect = true; + let shouldDisconnect = false; - elements.target1.style.width = '220px'; + observer = new ResizeObserver((...args) => { + spy(...args); - await Promise.race([spy.nextCall(), spy2.nextCall()]); - await wait(10); + if (shouldDisconnect) { + observer2.disconnect(); + } + }); - if (spy.calls.count() === 2) { - expect(spy2).toHaveBeenCalledTimes(1); - } + observer2 = new ResizeObserver((...args) => { + spy2(...args); - if (spy2.calls.count() === 2) { - expect(spy).toHaveBeenCalledTimes(1); - } - }).then(done).catch(done.fail); - }); + if (shouldDisconnect) { + observer.disconnect(); + } + }); - it('doesn\'t destroy observer', done => { - const spy = createAsyncSpy(); + observer.observe(elements.target1); + observer2.observe(elements.target1); - observer = new ResizeObserver(spy); + Promise.all([ + spy.nextCall(), + spy2.nextCall() + ]).then(async () => { + shouldDisconnect = true; - observer.observe(elements.target1); + elements.target1.style.width = '220px'; - spy.nextCall().then(async () => { - elements.target1.style.width = '600px'; + await Promise.race([spy.nextCall(), spy2.nextCall()]); + await wait(10); - observer.disconnect(); + if (spy.calls.count() === 2) { + expect(spy2).toHaveBeenCalledTimes(1); + } - await wait(timeout); + if (spy2.calls.count() === 2) { + expect(spy).toHaveBeenCalledTimes(1); + } + }).then(done).catch(done.fail); + }); + + it('doesn\'t destroy observer', done => { + const spy = createAsyncSpy(); + + observer = new ResizeObserver(spy); observer.observe(elements.target1); - const entries = await spy.nextCall(); + spy.nextCall().then(async () => { + elements.target1.style.width = '600px'; + + observer.disconnect(); + + await wait(timeout); + + observer.observe(elements.target1); + + const entries = await spy.nextCall(); + + expect(spy).toHaveBeenCalledTimes(2); + + expect(entries.length).toBe(1); - expect(spy).toHaveBeenCalledTimes(2); + expect(entries[0].target).toBe(elements.target1); + expect(entries[0].contentRect.width).toBe(600); + }).then(done).catch(done.fail); + }); + }); + } - expect(entries.length).toBe(1); + describe('Main DOM', () => { + beforeEach(() => { + appendStyles(document); + appendElements(document, document.body); + }); - expect(entries[0].target).toBe(elements.target1); - expect(entries[0].contentRect.width).toBe(600); - }).then(done).catch(done.fail); + afterEach(() => { + removeStyles(document); + removeElements(document, document.body); }); + + specForAnyRoot(); }); + + describe('same-origin iframe', () => { + let iframe, doc; + + beforeEach((done) => { + iframe = document.createElement('iframe'); + iframe.setAttribute('frameborder', '0'); + iframe.setAttribute('scrolling', 'yes'); + iframe.style.position = 'fixed'; + iframe.style.top = '0px'; + iframe.style.width = '100px'; + iframe.style.height = '200px'; + iframe.onerror = function () { + done(new Error('iframe initialization failed')); + }; + iframe.onload = function () { + iframe.onload = null; + const win = iframe.contentWindow; + + doc = win.document; + doc.open(); + doc.write(''); + doc.close(); + + appendStyles(doc); + appendElements(doc, doc.body); + + done(); + }; + iframe.src = 'about:blank'; + document.body.appendChild(iframe); + }); + + afterEach(() => { + removeStyles(doc); + removeElements(doc, doc.body); + document.body.removeChild(iframe); + }); + + specForAnyRoot(); + }); + + if (typeof document.body.attachShadow !== 'undefined') { + describe('Shadow DOM', () => { + let shadowRootHost, body; + + beforeEach(() => { + shadowRootHost = document.createElement('div'); + document.body.appendChild(shadowRootHost); + + shadowRootHost.attachShadow({mode: 'open'}); + body = document.createElement('div'); + shadowRootHost.shadowRoot.appendChild(body); + appendStyles(shadowRootHost.shadowRoot); + appendElements(shadowRootHost.shadowRoot, body); + }); + + afterEach(() => { + removeStyles(shadowRootHost.shadowRoot); + removeElements(shadowRootHost.shadowRoot, body); + document.body.removeChild(shadowRootHost); + }); + + specForAnyRoot(); + }); + } });