From 5a8563f12b9783cba5ae937060f913958bf183f5 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Tue, 9 Oct 2018 13:49:11 +0200 Subject: [PATCH 1/2] Allow running in node environment --- .size-snapshot.json | 6 +- jest.config.js | 2 +- package.json | 7 +- src/__tests__/element-queries.js | 9 +- src/__tests__/events.js | 3 +- src/__tests__/example.js | 1 + src/__tests__/get-queries-for-element.js | 1 + src/__tests__/helpers/document.js | 35 ++ src/__tests__/helpers/test-utils.js | 1 + src/__tests__/pretty-dom.js | 1 + src/__tests__/wait-for-element.js | 12 +- src/events.js | 199 +++---- src/get-node-text.js | 2 + src/query-helpers.js | 1 + src/vendor/mutationobserver.js | 727 +++++++++++++++++++++++ src/wait-for-element.js | 14 +- 16 files changed, 899 insertions(+), 122 deletions(-) create mode 100644 src/__tests__/helpers/document.js create mode 100644 src/vendor/mutationobserver.js diff --git a/.size-snapshot.json b/.size-snapshot.json index 094d20cb..0c62dc8f 100644 --- a/.size-snapshot.json +++ b/.size-snapshot.json @@ -1,7 +1,7 @@ { "dist/dom-testing-library.umd.js": { - "bundled": 115443, - "minified": 50960, - "gzipped": 15294 + "bundled": 144405, + "minified": 52722, + "gzipped": 15635 } } diff --git a/jest.config.js b/jest.config.js index 4160d66d..feca704e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,5 @@ const jestConfig = require('kcd-scripts/jest') module.exports = Object.assign(jestConfig, { - testEnvironment: 'jest-environment-jsdom', + testEnvironment: 'jest-environment-node', }) diff --git a/package.json b/package.json index 27221312..8c3c038e 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,9 @@ "build": "kcd-scripts build && kcd-scripts build --bundle umd --no-clean", "lint": "kcd-scripts lint", "test": "kcd-scripts test", + "test:all": "npm test && npm test -- --env jsdom", "test:update": "npm test -- --updateSnapshot --coverage", - "validate": "kcd-scripts validate", + "validate": "kcd-scripts validate build,lint,test:all", "setup": "npm install && npm run validate -s", "precommit": "kcd-scripts precommit", "dtslint": "dtslint typings" @@ -38,14 +39,14 @@ "typings" ], "dependencies": { - "mutationobserver-shim": "^0.3.2", "pretty-format": "^23.6.0", "wait-for-expect": "^1.0.0" }, "devDependencies": { "dtslint": "^0.3.0", - "jest-dom": "^1.7.0", + "jest-dom": "^2.0.4", "jest-in-case": "^1.0.2", + "jsdom": "^12.2.0", "kcd-scripts": "^0.41.0", "microbundle": "^0.4.4" }, diff --git a/src/__tests__/element-queries.js b/src/__tests__/element-queries.js index f0e0f6db..5374ec6b 100644 --- a/src/__tests__/element-queries.js +++ b/src/__tests__/element-queries.js @@ -1,8 +1,9 @@ import 'jest-dom/extend-expect' import {render} from './helpers/test-utils' +import document from './helpers/document' beforeEach(() => { - window.Cypress = null + document.defaultView.Cypress = null }) test('query can return null', () => { @@ -148,9 +149,7 @@ test('get element by its alt text', () => { finding nemo poster , `) - expect(getByAltText(/fin.*nem.*poster$/i).src).toBe( - 'http://localhost/finding-nemo.png', - ) + expect(getByAltText(/fin.*nem.*poster$/i).src).toContain('/finding-nemo.png') }) test('query/get element by its title', () => { @@ -489,7 +488,7 @@ test('test the debug helper prints the dom state here', () => { }) test('get throws a useful error message without DOM in Cypress', () => { - window.Cypress = {} + document.defaultView.Cypress = {} const { getByLabelText, getBySelectText, diff --git a/src/__tests__/events.js b/src/__tests__/events.js index cb3e7483..10b15f69 100644 --- a/src/__tests__/events.js +++ b/src/__tests__/events.js @@ -1,4 +1,5 @@ import {fireEvent} from '..' +import document from './helpers/document' const eventTypes = [ { @@ -171,7 +172,7 @@ test('assigning a value to a target that cannot have a value throws an error', ( test('assigning the files property on an input', () => { const node = document.createElement('input') - const file = new File(['(⌐□_□)'], 'chucknorris.png', { + const file = new document.defaultView.File(['(⌐□_□)'], 'chucknorris.png', { type: 'image/png', }) fireEvent.change(node, {target: {files: [file]}}) diff --git a/src/__tests__/example.js b/src/__tests__/example.js index da0bb8ba..f6a96dd6 100644 --- a/src/__tests__/example.js +++ b/src/__tests__/example.js @@ -2,6 +2,7 @@ import {getByLabelText, getByText, getByTestId, queryByTestId, wait} from '../' // adds special assertions like toHaveTextContent import 'jest-dom/extend-expect' +import document from './helpers/document' function getExampleDOM() { // This is just a raw example of setting up some DOM diff --git a/src/__tests__/get-queries-for-element.js b/src/__tests__/get-queries-for-element.js index 1a6afba8..44d2e43d 100644 --- a/src/__tests__/get-queries-for-element.js +++ b/src/__tests__/get-queries-for-element.js @@ -1,5 +1,6 @@ import {getQueriesForElement} from '../get-queries-for-element' import {queries} from '..' +import document from './helpers/document' test('uses default queries', () => { const container = document.createElement('div') diff --git a/src/__tests__/helpers/document.js b/src/__tests__/helpers/document.js new file mode 100644 index 00000000..64bb96b1 --- /dev/null +++ b/src/__tests__/helpers/document.js @@ -0,0 +1,35 @@ +let testWindow = typeof window === 'undefined' ? undefined : window + +if (typeof window === 'undefined') { + const {JSDOM} = require('jsdom') + const dom = new JSDOM() + testWindow = dom.window +} + +// TODO: these events are not supported by JSDOM so we need to shim them + +if (!testWindow.ClipboardEvent) { + testWindow.ClipboardEvent = class ClipboardEvent extends testWindow.Event {} +} + +if (!testWindow.DragEvent) { + testWindow.DragEvent = class DragEvent extends testWindow.Event {} +} + +if (!testWindow.TransitionEvent) { + testWindow.TransitionEvent = class TransitionEvent extends testWindow.Event {} +} + +if (!testWindow.AnimationEvent) { + testWindow.AnimationEvent = class AnimationEvent extends testWindow.Event {} +} + +if (!testWindow.AnimationEvent) { + testWindow.AnimationEvent = class AnimationEvent extends testWindow.Event {} +} + +if (!testWindow.InputEvent) { + testWindow.InputEvent = class InputEvent extends testWindow.Event {} +} + +module.exports = testWindow.document diff --git a/src/__tests__/helpers/test-utils.js b/src/__tests__/helpers/test-utils.js index a21a316b..46d270d9 100644 --- a/src/__tests__/helpers/test-utils.js +++ b/src/__tests__/helpers/test-utils.js @@ -1,4 +1,5 @@ import {getQueriesForElement} from '../../get-queries-for-element' +import document from './document' function render(html) { const container = document.createElement('div') diff --git a/src/__tests__/pretty-dom.js b/src/__tests__/pretty-dom.js index 312cf78b..b2fe3d02 100644 --- a/src/__tests__/pretty-dom.js +++ b/src/__tests__/pretty-dom.js @@ -1,5 +1,6 @@ import {prettyDOM} from '../pretty-dom' import {render} from './helpers/test-utils' +import document from './helpers/document' test('it prints out the given DOM element tree', () => { const {container} = render('
Hello World!
') diff --git a/src/__tests__/wait-for-element.js b/src/__tests__/wait-for-element.js index 1feaa619..247309e4 100644 --- a/src/__tests__/wait-for-element.js +++ b/src/__tests__/wait-for-element.js @@ -2,6 +2,7 @@ import {waitForElement, wait} from '../' // adds special assertions like toBeTruthy import 'jest-dom/extend-expect' import {render} from './helpers/test-utils' +import document from './helpers/document' async function skipSomeTime(delayMs) { await new Promise(resolve => setTimeout(resolve, delayMs)) @@ -104,7 +105,16 @@ test('it waits for the next DOM mutation with default callback', async () => { const successHandler = jest.fn().mockName('successHandler') const errorHandler = jest.fn().mockName('errorHandler') - const promise = waitForElement().then(successHandler, errorHandler) + let promise + + if (typeof window !== 'undefined' && typeof window.document !== 'undefined') { + promise = waitForElement().then(successHandler, errorHandler) + } else { + promise = waitForElement(undefined, {container: document}).then( + successHandler, + errorHandler, + ) + } // Promise callbacks are always asynchronous. expect(successHandler).toHaveBeenCalledTimes(0) diff --git a/src/events.js b/src/events.js index 51073c5b..59eac6ad 100644 --- a/src/events.js +++ b/src/events.js @@ -1,218 +1,201 @@ -const { - AnimationEvent, - ClipboardEvent, - CompositionEvent, - DragEvent, - Event, - FocusEvent, - InputEvent, - KeyboardEvent, - MouseEvent, - ProgressEvent, - TouchEvent, - TransitionEvent, - UIEvent, - WheelEvent, -} = typeof window === 'undefined' ? /* istanbul ignore next */ global : window - const eventMap = { // Clipboard Events copy: { - EventType: ClipboardEvent, + EventType: 'ClipboardEvent', defaultInit: {bubbles: true, cancelable: true}, }, cut: { - EventType: ClipboardEvent, + EventType: 'ClipboardEvent', defaultInit: {bubbles: true, cancelable: true}, }, paste: { - EventType: ClipboardEvent, + EventType: 'ClipboardEvent', defaultInit: {bubbles: true, cancelable: true}, }, // Composition Events compositionEnd: { - EventType: CompositionEvent, + EventType: 'CompositionEvent', defaultInit: {bubbles: true, cancelable: true}, }, compositionStart: { - EventType: CompositionEvent, + EventType: 'CompositionEvent', defaultInit: {bubbles: true, cancelable: true}, }, compositionUpdate: { - EventType: CompositionEvent, + EventType: 'CompositionEvent', defaultInit: {bubbles: true, cancelable: false}, }, // Keyboard Events keyDown: { - EventType: KeyboardEvent, + EventType: 'KeyboardEvent', defaultInit: {bubbles: true, cancelable: true}, }, keyPress: { - EventType: KeyboardEvent, + EventType: 'KeyboardEvent', defaultInit: {bubbles: true, cancelable: true}, }, keyUp: { - EventType: KeyboardEvent, + EventType: 'KeyboardEvent', defaultInit: {bubbles: true, cancelable: true}, }, // Focus Events focus: { - EventType: FocusEvent, + EventType: 'FocusEvent', defaultInit: {bubbles: false, cancelable: false}, }, blur: { - EventType: FocusEvent, + EventType: 'FocusEvent', defaultInit: {bubbles: false, cancelable: false}, }, // Form Events change: { - EventType: InputEvent, + EventType: 'InputEvent', defaultInit: {bubbles: true, cancelable: true}, }, input: { - EventType: InputEvent, + EventType: 'InputEvent', defaultInit: {bubbles: true, cancelable: true}, }, invalid: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: true}, }, submit: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: true, cancelable: true}, }, // Mouse Events click: { - EventType: MouseEvent, + EventType: 'MouseEvent', defaultInit: {bubbles: true, cancelable: true, button: 0}, }, contextMenu: { - EventType: MouseEvent, + EventType: 'MouseEvent', defaultInit: {bubbles: true, cancelable: true}, }, dblClick: { - EventType: MouseEvent, + EventType: 'MouseEvent', defaultInit: {bubbles: true, cancelable: true}, }, drag: { - EventType: DragEvent, + EventType: 'DragEvent', defaultInit: {bubbles: true, cancelable: true}, }, dragEnd: { - EventType: DragEvent, + EventType: 'DragEvent', defaultInit: {bubbles: true, cancelable: false}, }, dragEnter: { - EventType: DragEvent, + EventType: 'DragEvent', defaultInit: {bubbles: true, cancelable: true}, }, dragExit: { - EventType: DragEvent, + EventType: 'DragEvent', defaultInit: {bubbles: true, cancelable: false}, }, dragLeave: { - EventType: DragEvent, + EventType: 'DragEvent', defaultInit: {bubbles: true, cancelable: false}, }, dragOver: { - EventType: DragEvent, + EventType: 'DragEvent', defaultInit: {bubbles: true, cancelable: true}, }, dragStart: { - EventType: DragEvent, + EventType: 'DragEvent', defaultInit: {bubbles: true, cancelable: true}, }, drop: { - EventType: DragEvent, + EventType: 'DragEvent', defaultInit: {bubbles: true, cancelable: true}, }, mouseDown: { - EventType: MouseEvent, + EventType: 'MouseEvent', defaultInit: {bubbles: true, cancelable: true}, }, mouseEnter: { - EventType: MouseEvent, + EventType: 'MouseEvent', defaultInit: {bubbles: true, cancelable: true}, }, mouseLeave: { - EventType: MouseEvent, + EventType: 'MouseEvent', defaultInit: {bubbles: true, cancelable: true}, }, mouseMove: { - EventType: MouseEvent, + EventType: 'MouseEvent', defaultInit: {bubbles: true, cancelable: true}, }, mouseOut: { - EventType: MouseEvent, + EventType: 'MouseEvent', defaultInit: {bubbles: true, cancelable: true}, }, mouseOver: { - EventType: MouseEvent, + EventType: 'MouseEvent', defaultInit: {bubbles: true, cancelable: true}, }, mouseUp: { - EventType: MouseEvent, + EventType: 'MouseEvent', defaultInit: {bubbles: true, cancelable: true}, }, // Selection Events select: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: true, cancelable: false}, }, // Touch Events touchCancel: { - EventType: TouchEvent, + EventType: 'TouchEvent', defaultInit: {bubbles: true, cancelable: false}, }, touchEnd: { - EventType: TouchEvent, + EventType: 'TouchEvent', defaultInit: {bubbles: true, cancelable: true}, }, touchMove: { - EventType: TouchEvent, + EventType: 'TouchEvent', defaultInit: {bubbles: true, cancelable: true}, }, touchStart: { - EventType: TouchEvent, + EventType: 'TouchEvent', defaultInit: {bubbles: true, cancelable: true}, }, // UI Events scroll: { - EventType: UIEvent, + EventType: 'UIEvent', defaultInit: {bubbles: false, cancelable: false}, }, // Wheel Events wheel: { - EventType: WheelEvent, + EventType: 'WheelEvent', defaultInit: {bubbles: true, cancelable: true}, }, // Media Events abort: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, canPlay: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, canPlayThrough: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, durationChange: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, emptied: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, encrypted: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, ended: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, // error: { @@ -220,90 +203,90 @@ const eventMap = { // defaultInit: {bubbles: false, cancelable: false}, // }, loadedData: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, loadedMetadata: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, loadStart: { - EventType: ProgressEvent, + EventType: 'ProgressEvent', defaultInit: {bubbles: false, cancelable: false}, }, pause: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, play: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, playing: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, progress: { - EventType: ProgressEvent, + EventType: 'ProgressEvent', defaultInit: {bubbles: false, cancelable: false}, }, rateChange: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, seeked: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, seeking: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, stalled: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, suspend: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, timeUpdate: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, volumeChange: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, waiting: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, // Image Events load: { - EventType: UIEvent, + EventType: 'UIEvent', defaultInit: {bubbles: false, cancelable: false}, }, error: { - EventType: Event, + EventType: 'Event', defaultInit: {bubbles: false, cancelable: false}, }, // Animation Events animationStart: { - EventType: AnimationEvent, + EventType: 'AnimationEvent', defaultInit: {bubbles: true, cancelable: false}, }, animationEnd: { - EventType: AnimationEvent, + EventType: 'AnimationEvent', defaultInit: {bubbles: true, cancelable: false}, }, animationIteration: { - EventType: AnimationEvent, + EventType: 'AnimationEvent', defaultInit: {bubbles: true, cancelable: false}, }, // Transition Events transitionEnd: { - EventType: TransitionEvent, + EventType: 'TransitionEvent', defaultInit: {bubbles: true, cancelable: true}, }, } @@ -316,28 +299,34 @@ function fireEvent(element, event) { return element.dispatchEvent(event) } -Object.entries(eventMap).forEach(([key, {EventType = Event, defaultInit}]) => { - const eventName = key.toLowerCase() +Object.entries(eventMap).forEach( + ([key, {EventType = 'Event', defaultInit}]) => { + const eventName = key.toLowerCase() - fireEvent[key] = (node, init) => { - const eventInit = {...defaultInit, ...init} - const {target: {value, files, ...targetProperties} = {}} = eventInit - Object.assign(node, targetProperties) - if (value !== undefined) { - setNativeValue(node, value) - } - if (files !== undefined) { - // input.files is a read-only property so this is not allowed: - // input.files = [file] - // so we have to use this workaround to set the property - Object.defineProperty(node, 'files', { - value: files, - }) + fireEvent[key] = (node, init) => { + const eventInit = {...defaultInit, ...init} + const {target: {value, files, ...targetProperties} = {}} = eventInit + Object.assign(node, targetProperties) + if (value !== undefined) { + setNativeValue(node, value) + } + if (files !== undefined) { + // input.files is a read-only property so this is not allowed: + // input.files = [file] + // so we have to use this workaround to set the property + Object.defineProperty(node, 'files', { + value: files, + }) + } + const window = node.ownerDocument.defaultView + if (typeof window[EventType] === 'undefined') { + throw new Error(`${EventType} is not supported`) + } + const event = new window[EventType](eventName, eventInit) + return fireEvent(node, event) } - const event = new EventType(eventName, eventInit) - return fireEvent(node, event) - } -}) + }, +) // function written after some investigation here: // https://github.com/facebook/react/issues/10135#issuecomment-401496776 diff --git a/src/get-node-text.js b/src/get-node-text.js index cace4734..1f7c3ecd 100644 --- a/src/get-node-text.js +++ b/src/get-node-text.js @@ -1,4 +1,6 @@ function getNodeText(node) { + const window = node.ownerDocument.defaultView + return Array.from(node.childNodes) .filter( child => diff --git a/src/query-helpers.js b/src/query-helpers.js index 8a8e7751..3f3e7e56 100644 --- a/src/query-helpers.js +++ b/src/query-helpers.js @@ -8,6 +8,7 @@ function debugDOM(htmlElement) { typeof process !== 'undefined' && process.versions !== undefined && process.versions.node !== undefined + const window = htmlElement.ownerDocument.defaultView const inCypress = typeof window !== 'undefined' && window.Cypress /* istanbul ignore else */ if (inCypress) { diff --git a/src/vendor/mutationobserver.js b/src/vendor/mutationobserver.js new file mode 100644 index 00000000..aefde197 --- /dev/null +++ b/src/vendor/mutationobserver.js @@ -0,0 +1,727 @@ +/* eslint-disable */ + +/*! + * Shim for MutationObserver interface + * Author: Graeme Yeates (github.com/megawac) + * Repository: https://github.com/megawac/MutationObserver.js + * License: WTFPL V2, 2004 (wtfpl.net). + * Though credit and staring the repo will make me feel pretty, you can modify and redistribute as you please. + * Attempts to follow spec (https://www.w3.org/TR/dom/#mutation-observers) as closely as possible for native javascript + * See https://github.com/WebKit/webkit/blob/master/Source/WebCore/dom/MutationObserver.cpp for current webkit source c++ implementation + */ + +/** + * prefix bugs: + - https://bugs.webkit.org/show_bug.cgi?id=85161 + - https://bugzilla.mozilla.org/show_bug.cgi?id=749920 + * Don't use WebKitMutationObserver as Safari (6.0.5-6.1) use a buggy implementation +*/ +const MutationObserver = + global.MutationObserver || + (function(undefined) { + 'use strict' + /** + * @param {function(Array., MutationObserver)} listener + * @constructor + */ + function MutationObserver(listener) { + /** + * @type {Array.} + * @private + */ + this._watched = [] + /** @private */ + this._listener = listener + } + + /** + * Start a recursive timeout function to check all items being observed for mutations + * @type {MutationObserver} observer + * @private + */ + function startMutationChecker(observer) { + ;(function check() { + var mutations = observer.takeRecords() + + if (mutations.length) { + // fire away + // calling the listener with context is not spec but currently consistent with FF and WebKit + observer._listener(mutations, observer) + } + /** @private */ + observer._timeout = setTimeout(check, MutationObserver._period) + })() + } + + /** + * Period to check for mutations (~32 times/sec) + * @type {number} + * @expose + */ + MutationObserver._period = 30 /*ms+runtime*/ + + /** + * Exposed API + * @expose + * @final + */ + MutationObserver.prototype = { + /** + * see https://dom.spec.whatwg.org/#dom-mutationobserver-observe + * not going to throw here but going to follow the current spec config sets + * @param {Node|null} $target + * @param {Object|null} config : MutationObserverInit configuration dictionary + * @expose + * @return undefined + */ + observe: function($target, config) { + /** + * Using slightly different names so closure can go ham + * @type {!Object} : A custom mutation config + */ + var settings = { + attr: !!( + config.attributes || + config.attributeFilter || + config.attributeOldValue + ), + + // some browsers enforce that subtree must be set with childList, attributes or characterData. + // We don't care as spec doesn't specify this rule. + kids: !!config.childList, + descendents: !!config.subtree, + charData: !!(config.characterData || config.characterDataOldValue), + } + + var watched = this._watched + + // remove already observed target element from pool + for (var i = 0; i < watched.length; i++) { + if (watched[i].tar === $target) watched.splice(i, 1) + } + + if (config.attributeFilter) { + /** + * converts to a {key: true} dict for faster lookup + * @type {Object.} + */ + settings.afilter = reduce( + config.attributeFilter, + function(a, b) { + a[b] = true + return a + }, + {}, + ) + } + + watched.push({ + tar: $target, + fn: createMutationSearcher($target, settings), + }) + + // reconnect if not connected + if (!this._timeout) { + startMutationChecker(this) + } + }, + + /** + * Finds mutations since last check and empties the "record queue" i.e. mutations will only be found once + * @expose + * @return {Array.} + */ + takeRecords: function() { + var mutations = [] + var watched = this._watched + + for (var i = 0; i < watched.length; i++) { + watched[i].fn(mutations) + } + + return mutations + }, + + /** + * @expose + * @return undefined + */ + disconnect: function() { + this._watched = [] // clear the stuff being observed + clearTimeout(this._timeout) // ready for garbage collection + /** @private */ + this._timeout = null + }, + } + + /** + * Simple MutationRecord pseudoclass. No longer exposing as its not fully compliant + * @param {Object} data + * @return {Object} a MutationRecord + */ + function MutationRecord(data) { + var settings = { + // technically these should be on proto so hasOwnProperty will return false for non explicitly props + type: null, + target: null, + addedNodes: [], + removedNodes: [], + previousSibling: null, + nextSibling: null, + attributeName: null, + attributeNamespace: null, + oldValue: null, + } + for (var prop in data) { + if (has(settings, prop) && data[prop] !== undefined) + settings[prop] = data[prop] + } + return settings + } + + /** + * Creates a func to find all the mutations + * + * @param {Node} $target + * @param {!Object} config : A custom mutation config + */ + function createMutationSearcher($target, config) { + /** type {Elestuct} */ + var $oldstate = clone($target, config) // create the cloned datastructure + + /** + * consumes array of mutations we can push to + * + * @param {Array.} mutations + */ + return function(mutations) { + var olen = mutations.length, + dirty + + if ( + config.charData && + $target.nodeType === 3 && + $target.nodeValue !== $oldstate.charData + ) { + mutations.push( + new MutationRecord({ + type: 'characterData', + target: $target, + oldValue: $oldstate.charData, + }), + ) + } + + // Alright we check base level changes in attributes... easy + if (config.attr && $oldstate.attr) { + findAttributeMutations( + mutations, + $target, + $oldstate.attr, + config.afilter, + ) + } + + // check childlist or subtree for mutations + if (config.kids || config.descendents) { + dirty = searchSubtree(mutations, $target, $oldstate, config) + } + + // reclone data structure if theres changes + if (dirty || mutations.length !== olen) { + /** type {Elestuct} */ + $oldstate = clone($target, config) + } + } + } + + /* attributes + attributeFilter helpers */ + + // Check if the environment has the attribute bug (#4) which cause + // element.attributes.style to always be null. + var hasAttributeBug = false + if (typeof document !== 'undefined') { + var testElement = document.createElement('i') + testElement.style.top = 0 + hasAttributeBug = testElement.attributes.style.value != 'null' + } + + /** + * Gets an attribute value in an environment without attribute bug + * + * @param {Node} el + * @param {Attr} attr + * @return {String} an attribute value + */ + function getAttributeSimple(el, attr) { + // There is a potential for a warning to occur here if the attribute is a + // custom attribute in IE<9 with a custom .toString() method. This is + // just a warning and doesn't affect execution (see #21) + return attr.value + } + + /** + * Gets an attribute value with special hack for style attribute (see #4) + * + * @param {Node} el + * @param {Attr} attr + * @return {String} an attribute value + */ + function getAttributeWithStyleHack(el, attr) { + // As with getAttributeSimple there is a potential warning for custom attribtues in IE7. + return attr.name !== 'style' ? attr.value : el.style.cssText + } + + var getAttributeValue = hasAttributeBug + ? getAttributeSimple + : getAttributeWithStyleHack + + /** + * fast helper to check to see if attributes object of an element has changed + * doesnt handle the textnode case + * + * @param {Array.} mutations + * @param {Node} $target + * @param {Object.} $oldstate : Custom attribute clone data structure from clone + * @param {Object} filter + */ + function findAttributeMutations(mutations, $target, $oldstate, filter) { + var checked = {} + var attributes = $target.attributes + var attr + var name + var i = attributes.length + while (i--) { + attr = attributes[i] + name = attr.name + if (!filter || has(filter, name)) { + if (getAttributeValue($target, attr) !== $oldstate[name]) { + // The pushing is redundant but gzips very nicely + mutations.push( + MutationRecord({ + type: 'attributes', + target: $target, + attributeName: name, + oldValue: $oldstate[name], + attributeNamespace: attr.namespaceURI, // in ie<8 it incorrectly will return undefined + }), + ) + } + checked[name] = true + } + } + for (name in $oldstate) { + if (!checked[name]) { + mutations.push( + MutationRecord({ + target: $target, + type: 'attributes', + attributeName: name, + oldValue: $oldstate[name], + }), + ) + } + } + } + + /** + * searchSubtree: array of mutations so far, element, element clone, bool + * synchronous dfs comparision of two nodes + * This function is applied to any observed element with childList or subtree specified + * Sorry this is kind of confusing as shit, tried to comment it a bit... + * codereview.stackexchange.com/questions/38351 discussion of an earlier version of this func + * + * @param {Array} mutations + * @param {Node} $target + * @param {!Object} $oldstate : A custom cloned node from clone() + * @param {!Object} config : A custom mutation config + */ + function searchSubtree(mutations, $target, $oldstate, config) { + // Track if the tree is dirty and has to be recomputed (#14). + var dirty + /* + * Helper to identify node rearrangment and stuff... + * There is no gaurentee that the same node will be identified for both added and removed nodes + * if the positions have been shuffled. + * conflicts array will be emptied by end of operation + */ + function resolveConflicts( + conflicts, + node, + $kids, + $oldkids, + numAddedNodes, + ) { + // the distance between the first conflicting node and the last + var distance = conflicts.length - 1 + // prevents same conflict being resolved twice consider when two nodes switch places. + // only one should be given a mutation event (note -~ is used as a math.ceil shorthand) + var counter = -~((distance - numAddedNodes) / 2) + var $cur + var oldstruct + var conflict + while ((conflict = conflicts.pop())) { + $cur = $kids[conflict.i] + oldstruct = $oldkids[conflict.j] + + // attempt to determine if there was node rearrangement... won't gaurentee all matches + // also handles case where added/removed nodes cause nodes to be identified as conflicts + if ( + config.kids && + counter && + Math.abs(conflict.i - conflict.j) >= distance + ) { + mutations.push( + MutationRecord({ + type: 'childList', + target: node, + addedNodes: [$cur], + removedNodes: [$cur], + // haha don't rely on this please + nextSibling: $cur.nextSibling, + previousSibling: $cur.previousSibling, + }), + ) + counter-- // found conflict + } + + // Alright we found the resorted nodes now check for other types of mutations + if (config.attr && oldstruct.attr) + findAttributeMutations( + mutations, + $cur, + oldstruct.attr, + config.afilter, + ) + if ( + config.charData && + $cur.nodeType === 3 && + $cur.nodeValue !== oldstruct.charData + ) { + mutations.push( + MutationRecord({ + type: 'characterData', + target: $cur, + oldValue: oldstruct.charData, + }), + ) + } + // now look @ subtree + if (config.descendents) findMutations($cur, oldstruct) + } + } + + /** + * Main worker. Finds and adds mutations if there are any + * @param {Node} node + * @param {!Object} old : A cloned data structure using internal clone + */ + function findMutations(node, old) { + var $kids = node.childNodes + var $oldkids = old.kids + var klen = $kids.length + // $oldkids will be undefined for text and comment nodes + var olen = $oldkids ? $oldkids.length : 0 + // if (!olen && !klen) return; // both empty; clearly no changes + + // we delay the intialization of these for marginal performance in the expected case (actually quite signficant on large subtrees when these would be otherwise unused) + // map of checked element of ids to prevent registering the same conflict twice + var map + // array of potential conflicts (ie nodes that may have been re arranged) + var conflicts + var id // element id from getElementId helper + var idx // index of a moved or inserted element + + var oldstruct + // current and old nodes + var $cur + var $old + // track the number of added nodes so we can resolve conflicts more accurately + var numAddedNodes = 0 + + // iterate over both old and current child nodes at the same time + var i = 0, + j = 0 + // while there is still anything left in $kids or $oldkids (same as i < $kids.length || j < $oldkids.length;) + while (i < klen || j < olen) { + // current and old nodes at the indexs + $cur = $kids[i] + oldstruct = $oldkids[j] + $old = oldstruct && oldstruct.node + + if ($cur === $old) { + // expected case - optimized for this case + // check attributes as specified by config + if (config.attr && oldstruct.attr) + /* oldstruct.attr instead of textnode check */ findAttributeMutations( + mutations, + $cur, + oldstruct.attr, + config.afilter, + ) + // check character data if node is a comment or textNode and it's being observed + if ( + config.charData && + oldstruct.charData !== undefined && + $cur.nodeValue !== oldstruct.charData + ) { + mutations.push( + MutationRecord({ + type: 'characterData', + target: $cur, + oldValue: oldstruct.charData, + }), + ) + } + + // resolve conflicts; it will be undefined if there are no conflicts - otherwise an array + if (conflicts) + resolveConflicts(conflicts, node, $kids, $oldkids, numAddedNodes) + + // recurse on next level of children. Avoids the recursive call when there are no children left to iterate + if ( + config.descendents && + ($cur.childNodes.length || + (oldstruct.kids && oldstruct.kids.length)) + ) + findMutations($cur, oldstruct) + + i++ + j++ + } else { + // (uncommon case) lookahead until they are the same again or the end of children + dirty = true + if (!map) { + // delayed initalization (big perf benefit) + map = {} + conflicts = [] + } + if ($cur) { + // check id is in the location map otherwise do a indexOf search + if (!map[(id = getElementId($cur))]) { + // to prevent double checking + // mark id as found + map[id] = true + // custom indexOf using comparitor checking oldkids[i].node === $cur + if ((idx = indexOfCustomNode($oldkids, $cur, j)) === -1) { + if (config.kids) { + mutations.push( + MutationRecord({ + type: 'childList', + target: node, + addedNodes: [$cur], // $cur is a new node + nextSibling: $cur.nextSibling, + previousSibling: $cur.previousSibling, + }), + ) + numAddedNodes++ + } + } else { + conflicts.push({ + // add conflict + i: i, + j: idx, + }) + } + } + i++ + } + + if ( + $old && + // special case: the changes may have been resolved: i and j appear congurent so we can continue using the expected case + $old !== $kids[i] + ) { + if (!map[(id = getElementId($old))]) { + map[id] = true + if ((idx = indexOf($kids, $old, i)) === -1) { + if (config.kids) { + mutations.push( + MutationRecord({ + type: 'childList', + target: old.node, + removedNodes: [$old], + nextSibling: $oldkids[j + 1], // praise no indexoutofbounds exception + previousSibling: $oldkids[j - 1], + }), + ) + numAddedNodes-- + } + } else { + conflicts.push({ + i: idx, + j: j, + }) + } + } + j++ + } + } // end uncommon case + } // end loop + + // resolve any remaining conflicts + if (conflicts) + resolveConflicts(conflicts, node, $kids, $oldkids, numAddedNodes) + } + findMutations($target, $oldstate) + return dirty + } + + /** + * Utility + * Cones a element into a custom data structure designed for comparision. https://gist.github.com/megawac/8201012 + * + * @param {Node} $target + * @param {!Object} config : A custom mutation config + * @return {!Object} : Cloned data structure + */ + function clone($target, config) { + var recurse = true // set true so childList we'll always check the first level + return (function copy($target) { + var elestruct = { + /** @type {Node} */ + node: $target, + } + + // Store current character data of target text or comment node if the config requests + // those properties to be observed. + if ( + config.charData && + ($target.nodeType === 3 || $target.nodeType === 8) + ) { + elestruct.charData = $target.nodeValue + } + // its either a element, comment, doc frag or document node + else { + // Add attr only if subtree is specified or top level and avoid if + // attributes is a document object (#13). + if (config.attr && recurse && $target.nodeType === 1) { + /** + * clone live attribute list to an object structure {name: val} + * @type {Object.} + */ + elestruct.attr = reduce( + $target.attributes, + function(memo, attr) { + if (!config.afilter || config.afilter[attr.name]) { + memo[attr.name] = getAttributeValue($target, attr) + } + return memo + }, + {}, + ) + } + + // whether we should iterate the children of $target node + if ( + recurse && + (config.kids || + config.charData || + (config.attr && config.descendents)) + ) { + /** @type {Array.} : Array of custom clone */ + elestruct.kids = map($target.childNodes, copy) + } + + recurse = config.descendents + } + return elestruct + })($target) + } + + /** + * indexOf an element in a collection of custom nodes + * + * @param {NodeList} set + * @param {!Object} $node : A custom cloned node + * @param {number} idx : index to start the loop + * @return {number} + */ + function indexOfCustomNode(set, $node, idx) { + return indexOf(set, $node, idx, JSCompiler_renameProperty('node')) + } + + // using a non id (eg outerHTML or nodeValue) is extremely naive and will run into issues with nodes that may appear the same like
  • + var counter = 1 // don't use 0 as id (falsy) + /** @const */ + var expando = 'mo_id' + + /** + * Attempt to uniquely id an element for hashing. We could optimize this for legacy browsers but it hopefully wont be called enough to be a concern + * + * @param {Node} $ele + * @return {(string|number)} + */ + function getElementId($ele) { + try { + return $ele.id || ($ele[expando] = $ele[expando] || counter++) + } catch (o_O) { + // ie <8 will throw if you set an unknown property on a text node + try { + return $ele.nodeValue // naive + } catch (shitie) { + // when text node is removed: https://gist.github.com/megawac/8355978 :( + return counter++ + } + } + } + + /** + * **map** Apply a mapping function to each item of a set + * @param {Array|NodeList} set + * @param {Function} iterator + */ + function map(set, iterator) { + var results = [] + for (var index = 0; index < set.length; index++) { + results[index] = iterator(set[index], index, set) + } + return results + } + + /** + * **Reduce** builds up a single result from a list of values + * @param {Array|NodeList|NamedNodeMap} set + * @param {Function} iterator + * @param {*} [memo] Initial value of the memo. + */ + function reduce(set, iterator, memo) { + for (var index = 0; index < set.length; index++) { + memo = iterator(memo, set[index], index, set) + } + return memo + } + + /** + * **indexOf** find index of item in collection. + * @param {Array|NodeList} set + * @param {Object} item + * @param {number} idx + * @param {string} [prop] Property on set item to compare to item + */ + function indexOf(set, item, idx, prop) { + for (; /*idx = ~~idx*/ idx < set.length; idx++) { + // start idx is always given as this is internal + if ((prop ? set[idx][prop] : set[idx]) === item) return idx + } + return -1 + } + + /** + * @param {Object} obj + * @param {(string|number)} prop + * @return {boolean} + */ + function has(obj, prop) { + return obj[prop] !== undefined // will be nicely inlined by gcc + } + + // GCC hack see https://stackoverflow.com/a/23202438/1517919 + function JSCompiler_renameProperty(a) { + return a + } + + return MutationObserver + })(void 0) + +export default MutationObserver diff --git a/src/wait-for-element.js b/src/wait-for-element.js index 1bed5511..c411bb94 100644 --- a/src/wait-for-element.js +++ b/src/wait-for-element.js @@ -1,9 +1,9 @@ -import 'mutationobserver-shim' +import MutationObserver from './vendor/mutationobserver' function waitForElement( callback = undefined, { - container = document, + container, timeout = 4500, mutationObserverOptions = { subtree: true, @@ -13,6 +13,14 @@ function waitForElement( }, } = {}, ) { + if (typeof container === 'undefined') { + if (typeof document === 'undefined') { + throw new Error('Could not find default container') + } else { + container = document + } + } + return new Promise((resolve, reject) => { // Disabling eslint prefer-const below: either prefer-const or no-use-before-define triggers. let lastError, observer, timer // eslint-disable-line prefer-const @@ -46,7 +54,7 @@ function waitForElement( onDone(lastError || new Error('Timed out in waitForElement.'), null) } timer = setTimeout(onTimeout, timeout) - observer = new window.MutationObserver(onMutation) + observer = new MutationObserver(onMutation) observer.observe(container, mutationObserverOptions) if (callback !== undefined) { onMutation() From ab0d930538a83d4c4fd04ab87f35a2219a94a563 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Tue, 9 Oct 2018 22:46:41 +0200 Subject: [PATCH 2/2] Switch to forked mutationobserver-shim --- .size-snapshot.json | 6 +- package.json | 1 + src/vendor/mutationobserver.js | 727 --------------------------------- src/wait-for-element.js | 2 +- 4 files changed, 5 insertions(+), 731 deletions(-) delete mode 100644 src/vendor/mutationobserver.js diff --git a/.size-snapshot.json b/.size-snapshot.json index 0c62dc8f..8b346d5f 100644 --- a/.size-snapshot.json +++ b/.size-snapshot.json @@ -1,7 +1,7 @@ { "dist/dom-testing-library.umd.js": { - "bundled": 144405, - "minified": 52722, - "gzipped": 15635 + "bundled": 144335, + "minified": 52790, + "gzipped": 15639 } } diff --git a/package.json b/package.json index 8c3c038e..b47bc1ee 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "typings" ], "dependencies": { + "@sheerun/mutationobserver-shim": "^0.3.2", "pretty-format": "^23.6.0", "wait-for-expect": "^1.0.0" }, diff --git a/src/vendor/mutationobserver.js b/src/vendor/mutationobserver.js deleted file mode 100644 index aefde197..00000000 --- a/src/vendor/mutationobserver.js +++ /dev/null @@ -1,727 +0,0 @@ -/* eslint-disable */ - -/*! - * Shim for MutationObserver interface - * Author: Graeme Yeates (github.com/megawac) - * Repository: https://github.com/megawac/MutationObserver.js - * License: WTFPL V2, 2004 (wtfpl.net). - * Though credit and staring the repo will make me feel pretty, you can modify and redistribute as you please. - * Attempts to follow spec (https://www.w3.org/TR/dom/#mutation-observers) as closely as possible for native javascript - * See https://github.com/WebKit/webkit/blob/master/Source/WebCore/dom/MutationObserver.cpp for current webkit source c++ implementation - */ - -/** - * prefix bugs: - - https://bugs.webkit.org/show_bug.cgi?id=85161 - - https://bugzilla.mozilla.org/show_bug.cgi?id=749920 - * Don't use WebKitMutationObserver as Safari (6.0.5-6.1) use a buggy implementation -*/ -const MutationObserver = - global.MutationObserver || - (function(undefined) { - 'use strict' - /** - * @param {function(Array., MutationObserver)} listener - * @constructor - */ - function MutationObserver(listener) { - /** - * @type {Array.} - * @private - */ - this._watched = [] - /** @private */ - this._listener = listener - } - - /** - * Start a recursive timeout function to check all items being observed for mutations - * @type {MutationObserver} observer - * @private - */ - function startMutationChecker(observer) { - ;(function check() { - var mutations = observer.takeRecords() - - if (mutations.length) { - // fire away - // calling the listener with context is not spec but currently consistent with FF and WebKit - observer._listener(mutations, observer) - } - /** @private */ - observer._timeout = setTimeout(check, MutationObserver._period) - })() - } - - /** - * Period to check for mutations (~32 times/sec) - * @type {number} - * @expose - */ - MutationObserver._period = 30 /*ms+runtime*/ - - /** - * Exposed API - * @expose - * @final - */ - MutationObserver.prototype = { - /** - * see https://dom.spec.whatwg.org/#dom-mutationobserver-observe - * not going to throw here but going to follow the current spec config sets - * @param {Node|null} $target - * @param {Object|null} config : MutationObserverInit configuration dictionary - * @expose - * @return undefined - */ - observe: function($target, config) { - /** - * Using slightly different names so closure can go ham - * @type {!Object} : A custom mutation config - */ - var settings = { - attr: !!( - config.attributes || - config.attributeFilter || - config.attributeOldValue - ), - - // some browsers enforce that subtree must be set with childList, attributes or characterData. - // We don't care as spec doesn't specify this rule. - kids: !!config.childList, - descendents: !!config.subtree, - charData: !!(config.characterData || config.characterDataOldValue), - } - - var watched = this._watched - - // remove already observed target element from pool - for (var i = 0; i < watched.length; i++) { - if (watched[i].tar === $target) watched.splice(i, 1) - } - - if (config.attributeFilter) { - /** - * converts to a {key: true} dict for faster lookup - * @type {Object.} - */ - settings.afilter = reduce( - config.attributeFilter, - function(a, b) { - a[b] = true - return a - }, - {}, - ) - } - - watched.push({ - tar: $target, - fn: createMutationSearcher($target, settings), - }) - - // reconnect if not connected - if (!this._timeout) { - startMutationChecker(this) - } - }, - - /** - * Finds mutations since last check and empties the "record queue" i.e. mutations will only be found once - * @expose - * @return {Array.} - */ - takeRecords: function() { - var mutations = [] - var watched = this._watched - - for (var i = 0; i < watched.length; i++) { - watched[i].fn(mutations) - } - - return mutations - }, - - /** - * @expose - * @return undefined - */ - disconnect: function() { - this._watched = [] // clear the stuff being observed - clearTimeout(this._timeout) // ready for garbage collection - /** @private */ - this._timeout = null - }, - } - - /** - * Simple MutationRecord pseudoclass. No longer exposing as its not fully compliant - * @param {Object} data - * @return {Object} a MutationRecord - */ - function MutationRecord(data) { - var settings = { - // technically these should be on proto so hasOwnProperty will return false for non explicitly props - type: null, - target: null, - addedNodes: [], - removedNodes: [], - previousSibling: null, - nextSibling: null, - attributeName: null, - attributeNamespace: null, - oldValue: null, - } - for (var prop in data) { - if (has(settings, prop) && data[prop] !== undefined) - settings[prop] = data[prop] - } - return settings - } - - /** - * Creates a func to find all the mutations - * - * @param {Node} $target - * @param {!Object} config : A custom mutation config - */ - function createMutationSearcher($target, config) { - /** type {Elestuct} */ - var $oldstate = clone($target, config) // create the cloned datastructure - - /** - * consumes array of mutations we can push to - * - * @param {Array.} mutations - */ - return function(mutations) { - var olen = mutations.length, - dirty - - if ( - config.charData && - $target.nodeType === 3 && - $target.nodeValue !== $oldstate.charData - ) { - mutations.push( - new MutationRecord({ - type: 'characterData', - target: $target, - oldValue: $oldstate.charData, - }), - ) - } - - // Alright we check base level changes in attributes... easy - if (config.attr && $oldstate.attr) { - findAttributeMutations( - mutations, - $target, - $oldstate.attr, - config.afilter, - ) - } - - // check childlist or subtree for mutations - if (config.kids || config.descendents) { - dirty = searchSubtree(mutations, $target, $oldstate, config) - } - - // reclone data structure if theres changes - if (dirty || mutations.length !== olen) { - /** type {Elestuct} */ - $oldstate = clone($target, config) - } - } - } - - /* attributes + attributeFilter helpers */ - - // Check if the environment has the attribute bug (#4) which cause - // element.attributes.style to always be null. - var hasAttributeBug = false - if (typeof document !== 'undefined') { - var testElement = document.createElement('i') - testElement.style.top = 0 - hasAttributeBug = testElement.attributes.style.value != 'null' - } - - /** - * Gets an attribute value in an environment without attribute bug - * - * @param {Node} el - * @param {Attr} attr - * @return {String} an attribute value - */ - function getAttributeSimple(el, attr) { - // There is a potential for a warning to occur here if the attribute is a - // custom attribute in IE<9 with a custom .toString() method. This is - // just a warning and doesn't affect execution (see #21) - return attr.value - } - - /** - * Gets an attribute value with special hack for style attribute (see #4) - * - * @param {Node} el - * @param {Attr} attr - * @return {String} an attribute value - */ - function getAttributeWithStyleHack(el, attr) { - // As with getAttributeSimple there is a potential warning for custom attribtues in IE7. - return attr.name !== 'style' ? attr.value : el.style.cssText - } - - var getAttributeValue = hasAttributeBug - ? getAttributeSimple - : getAttributeWithStyleHack - - /** - * fast helper to check to see if attributes object of an element has changed - * doesnt handle the textnode case - * - * @param {Array.} mutations - * @param {Node} $target - * @param {Object.} $oldstate : Custom attribute clone data structure from clone - * @param {Object} filter - */ - function findAttributeMutations(mutations, $target, $oldstate, filter) { - var checked = {} - var attributes = $target.attributes - var attr - var name - var i = attributes.length - while (i--) { - attr = attributes[i] - name = attr.name - if (!filter || has(filter, name)) { - if (getAttributeValue($target, attr) !== $oldstate[name]) { - // The pushing is redundant but gzips very nicely - mutations.push( - MutationRecord({ - type: 'attributes', - target: $target, - attributeName: name, - oldValue: $oldstate[name], - attributeNamespace: attr.namespaceURI, // in ie<8 it incorrectly will return undefined - }), - ) - } - checked[name] = true - } - } - for (name in $oldstate) { - if (!checked[name]) { - mutations.push( - MutationRecord({ - target: $target, - type: 'attributes', - attributeName: name, - oldValue: $oldstate[name], - }), - ) - } - } - } - - /** - * searchSubtree: array of mutations so far, element, element clone, bool - * synchronous dfs comparision of two nodes - * This function is applied to any observed element with childList or subtree specified - * Sorry this is kind of confusing as shit, tried to comment it a bit... - * codereview.stackexchange.com/questions/38351 discussion of an earlier version of this func - * - * @param {Array} mutations - * @param {Node} $target - * @param {!Object} $oldstate : A custom cloned node from clone() - * @param {!Object} config : A custom mutation config - */ - function searchSubtree(mutations, $target, $oldstate, config) { - // Track if the tree is dirty and has to be recomputed (#14). - var dirty - /* - * Helper to identify node rearrangment and stuff... - * There is no gaurentee that the same node will be identified for both added and removed nodes - * if the positions have been shuffled. - * conflicts array will be emptied by end of operation - */ - function resolveConflicts( - conflicts, - node, - $kids, - $oldkids, - numAddedNodes, - ) { - // the distance between the first conflicting node and the last - var distance = conflicts.length - 1 - // prevents same conflict being resolved twice consider when two nodes switch places. - // only one should be given a mutation event (note -~ is used as a math.ceil shorthand) - var counter = -~((distance - numAddedNodes) / 2) - var $cur - var oldstruct - var conflict - while ((conflict = conflicts.pop())) { - $cur = $kids[conflict.i] - oldstruct = $oldkids[conflict.j] - - // attempt to determine if there was node rearrangement... won't gaurentee all matches - // also handles case where added/removed nodes cause nodes to be identified as conflicts - if ( - config.kids && - counter && - Math.abs(conflict.i - conflict.j) >= distance - ) { - mutations.push( - MutationRecord({ - type: 'childList', - target: node, - addedNodes: [$cur], - removedNodes: [$cur], - // haha don't rely on this please - nextSibling: $cur.nextSibling, - previousSibling: $cur.previousSibling, - }), - ) - counter-- // found conflict - } - - // Alright we found the resorted nodes now check for other types of mutations - if (config.attr && oldstruct.attr) - findAttributeMutations( - mutations, - $cur, - oldstruct.attr, - config.afilter, - ) - if ( - config.charData && - $cur.nodeType === 3 && - $cur.nodeValue !== oldstruct.charData - ) { - mutations.push( - MutationRecord({ - type: 'characterData', - target: $cur, - oldValue: oldstruct.charData, - }), - ) - } - // now look @ subtree - if (config.descendents) findMutations($cur, oldstruct) - } - } - - /** - * Main worker. Finds and adds mutations if there are any - * @param {Node} node - * @param {!Object} old : A cloned data structure using internal clone - */ - function findMutations(node, old) { - var $kids = node.childNodes - var $oldkids = old.kids - var klen = $kids.length - // $oldkids will be undefined for text and comment nodes - var olen = $oldkids ? $oldkids.length : 0 - // if (!olen && !klen) return; // both empty; clearly no changes - - // we delay the intialization of these for marginal performance in the expected case (actually quite signficant on large subtrees when these would be otherwise unused) - // map of checked element of ids to prevent registering the same conflict twice - var map - // array of potential conflicts (ie nodes that may have been re arranged) - var conflicts - var id // element id from getElementId helper - var idx // index of a moved or inserted element - - var oldstruct - // current and old nodes - var $cur - var $old - // track the number of added nodes so we can resolve conflicts more accurately - var numAddedNodes = 0 - - // iterate over both old and current child nodes at the same time - var i = 0, - j = 0 - // while there is still anything left in $kids or $oldkids (same as i < $kids.length || j < $oldkids.length;) - while (i < klen || j < olen) { - // current and old nodes at the indexs - $cur = $kids[i] - oldstruct = $oldkids[j] - $old = oldstruct && oldstruct.node - - if ($cur === $old) { - // expected case - optimized for this case - // check attributes as specified by config - if (config.attr && oldstruct.attr) - /* oldstruct.attr instead of textnode check */ findAttributeMutations( - mutations, - $cur, - oldstruct.attr, - config.afilter, - ) - // check character data if node is a comment or textNode and it's being observed - if ( - config.charData && - oldstruct.charData !== undefined && - $cur.nodeValue !== oldstruct.charData - ) { - mutations.push( - MutationRecord({ - type: 'characterData', - target: $cur, - oldValue: oldstruct.charData, - }), - ) - } - - // resolve conflicts; it will be undefined if there are no conflicts - otherwise an array - if (conflicts) - resolveConflicts(conflicts, node, $kids, $oldkids, numAddedNodes) - - // recurse on next level of children. Avoids the recursive call when there are no children left to iterate - if ( - config.descendents && - ($cur.childNodes.length || - (oldstruct.kids && oldstruct.kids.length)) - ) - findMutations($cur, oldstruct) - - i++ - j++ - } else { - // (uncommon case) lookahead until they are the same again or the end of children - dirty = true - if (!map) { - // delayed initalization (big perf benefit) - map = {} - conflicts = [] - } - if ($cur) { - // check id is in the location map otherwise do a indexOf search - if (!map[(id = getElementId($cur))]) { - // to prevent double checking - // mark id as found - map[id] = true - // custom indexOf using comparitor checking oldkids[i].node === $cur - if ((idx = indexOfCustomNode($oldkids, $cur, j)) === -1) { - if (config.kids) { - mutations.push( - MutationRecord({ - type: 'childList', - target: node, - addedNodes: [$cur], // $cur is a new node - nextSibling: $cur.nextSibling, - previousSibling: $cur.previousSibling, - }), - ) - numAddedNodes++ - } - } else { - conflicts.push({ - // add conflict - i: i, - j: idx, - }) - } - } - i++ - } - - if ( - $old && - // special case: the changes may have been resolved: i and j appear congurent so we can continue using the expected case - $old !== $kids[i] - ) { - if (!map[(id = getElementId($old))]) { - map[id] = true - if ((idx = indexOf($kids, $old, i)) === -1) { - if (config.kids) { - mutations.push( - MutationRecord({ - type: 'childList', - target: old.node, - removedNodes: [$old], - nextSibling: $oldkids[j + 1], // praise no indexoutofbounds exception - previousSibling: $oldkids[j - 1], - }), - ) - numAddedNodes-- - } - } else { - conflicts.push({ - i: idx, - j: j, - }) - } - } - j++ - } - } // end uncommon case - } // end loop - - // resolve any remaining conflicts - if (conflicts) - resolveConflicts(conflicts, node, $kids, $oldkids, numAddedNodes) - } - findMutations($target, $oldstate) - return dirty - } - - /** - * Utility - * Cones a element into a custom data structure designed for comparision. https://gist.github.com/megawac/8201012 - * - * @param {Node} $target - * @param {!Object} config : A custom mutation config - * @return {!Object} : Cloned data structure - */ - function clone($target, config) { - var recurse = true // set true so childList we'll always check the first level - return (function copy($target) { - var elestruct = { - /** @type {Node} */ - node: $target, - } - - // Store current character data of target text or comment node if the config requests - // those properties to be observed. - if ( - config.charData && - ($target.nodeType === 3 || $target.nodeType === 8) - ) { - elestruct.charData = $target.nodeValue - } - // its either a element, comment, doc frag or document node - else { - // Add attr only if subtree is specified or top level and avoid if - // attributes is a document object (#13). - if (config.attr && recurse && $target.nodeType === 1) { - /** - * clone live attribute list to an object structure {name: val} - * @type {Object.} - */ - elestruct.attr = reduce( - $target.attributes, - function(memo, attr) { - if (!config.afilter || config.afilter[attr.name]) { - memo[attr.name] = getAttributeValue($target, attr) - } - return memo - }, - {}, - ) - } - - // whether we should iterate the children of $target node - if ( - recurse && - (config.kids || - config.charData || - (config.attr && config.descendents)) - ) { - /** @type {Array.} : Array of custom clone */ - elestruct.kids = map($target.childNodes, copy) - } - - recurse = config.descendents - } - return elestruct - })($target) - } - - /** - * indexOf an element in a collection of custom nodes - * - * @param {NodeList} set - * @param {!Object} $node : A custom cloned node - * @param {number} idx : index to start the loop - * @return {number} - */ - function indexOfCustomNode(set, $node, idx) { - return indexOf(set, $node, idx, JSCompiler_renameProperty('node')) - } - - // using a non id (eg outerHTML or nodeValue) is extremely naive and will run into issues with nodes that may appear the same like
  • - var counter = 1 // don't use 0 as id (falsy) - /** @const */ - var expando = 'mo_id' - - /** - * Attempt to uniquely id an element for hashing. We could optimize this for legacy browsers but it hopefully wont be called enough to be a concern - * - * @param {Node} $ele - * @return {(string|number)} - */ - function getElementId($ele) { - try { - return $ele.id || ($ele[expando] = $ele[expando] || counter++) - } catch (o_O) { - // ie <8 will throw if you set an unknown property on a text node - try { - return $ele.nodeValue // naive - } catch (shitie) { - // when text node is removed: https://gist.github.com/megawac/8355978 :( - return counter++ - } - } - } - - /** - * **map** Apply a mapping function to each item of a set - * @param {Array|NodeList} set - * @param {Function} iterator - */ - function map(set, iterator) { - var results = [] - for (var index = 0; index < set.length; index++) { - results[index] = iterator(set[index], index, set) - } - return results - } - - /** - * **Reduce** builds up a single result from a list of values - * @param {Array|NodeList|NamedNodeMap} set - * @param {Function} iterator - * @param {*} [memo] Initial value of the memo. - */ - function reduce(set, iterator, memo) { - for (var index = 0; index < set.length; index++) { - memo = iterator(memo, set[index], index, set) - } - return memo - } - - /** - * **indexOf** find index of item in collection. - * @param {Array|NodeList} set - * @param {Object} item - * @param {number} idx - * @param {string} [prop] Property on set item to compare to item - */ - function indexOf(set, item, idx, prop) { - for (; /*idx = ~~idx*/ idx < set.length; idx++) { - // start idx is always given as this is internal - if ((prop ? set[idx][prop] : set[idx]) === item) return idx - } - return -1 - } - - /** - * @param {Object} obj - * @param {(string|number)} prop - * @return {boolean} - */ - function has(obj, prop) { - return obj[prop] !== undefined // will be nicely inlined by gcc - } - - // GCC hack see https://stackoverflow.com/a/23202438/1517919 - function JSCompiler_renameProperty(a) { - return a - } - - return MutationObserver - })(void 0) - -export default MutationObserver diff --git a/src/wait-for-element.js b/src/wait-for-element.js index c411bb94..c2ae0777 100644 --- a/src/wait-for-element.js +++ b/src/wait-for-element.js @@ -1,4 +1,4 @@ -import MutationObserver from './vendor/mutationobserver' +import MutationObserver from '@sheerun/mutationobserver-shim' function waitForElement( callback = undefined,