From a7b2294c1162cb97fa60eb33921eb13006d06225 Mon Sep 17 00:00:00 2001 From: impinball Date: Tue, 15 Dec 2015 07:07:50 -0500 Subject: [PATCH] OO-ize DOM builder, improve performance (part 1), add benchmarking suite --- .eslintignore | 2 +- bench/README.md | 4 + bench/app/.gitignore | 6 + bench/app/index.html | 26 + bench/app/js/app.js | 13 + bench/app/js/controllers/todo.js | 111 ++ bench/app/js/models/storage.js | 14 + bench/app/js/models/todo.js | 12 + bench/app/js/views/footer-view.js | 40 + bench/app/js/views/main-view.js | 119 ++ bench/app/package.json | 7 + bench/app/readme.md | 18 + bench/index.html | 81 ++ bench/resources/benchmark-runner.js | 268 +++++ bench/resources/manager.js | 237 ++++ bench/resources/tests.js | 51 + mithril.js | 1678 ++++++++++++++------------- package.json | 3 +- test/index.html | 5 +- test/mithril.js | 15 + test/mithril.render.js | 44 +- test/mithril.trust.js | 12 +- 22 files changed, 1953 insertions(+), 813 deletions(-) create mode 100644 bench/README.md create mode 100644 bench/app/.gitignore create mode 100644 bench/app/index.html create mode 100644 bench/app/js/app.js create mode 100644 bench/app/js/controllers/todo.js create mode 100644 bench/app/js/models/storage.js create mode 100644 bench/app/js/models/todo.js create mode 100644 bench/app/js/views/footer-view.js create mode 100644 bench/app/js/views/main-view.js create mode 100644 bench/app/package.json create mode 100644 bench/app/readme.md create mode 100644 bench/index.html create mode 100644 bench/resources/benchmark-runner.js create mode 100644 bench/resources/manager.js create mode 100644 bench/resources/tests.js diff --git a/.eslintignore b/.eslintignore index a6ff032f..0b24c5c3 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,5 @@ # Most of these are build artifacts. -node_modules +**/node_modules **/*.min.js archive deploy diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 00000000..16fcc8b9 --- /dev/null +++ b/bench/README.md @@ -0,0 +1,4 @@ +This suite is taken with modifications from the Mithril example in +[Merri/nom](https://github.com/Merri/nom/tree/gh-pages/todomvc/benchmark/mithril-new). + +(For example, it uses Mithril itself to help simplify the business logic.) diff --git a/bench/app/.gitignore b/bench/app/.gitignore new file mode 100644 index 00000000..11c90f9c --- /dev/null +++ b/bench/app/.gitignore @@ -0,0 +1,6 @@ +node_modules/todomvc-app-css/* +!node_modules/todomvc-app-css/index.css + +node_modules/todomvc-common/* +!node_modules/todomvc-common/base.js +!node_modules/todomvc-common/base.css diff --git a/bench/app/index.html b/bench/app/index.html new file mode 100644 index 00000000..e42cfacf --- /dev/null +++ b/bench/app/index.html @@ -0,0 +1,26 @@ + + + + + Mithril • TodoMVC + + + + +
+ + + + + + + + + + + diff --git a/bench/app/js/app.js b/bench/app/js/app.js new file mode 100644 index 00000000..d4a8e6fb --- /dev/null +++ b/bench/app/js/app.js @@ -0,0 +1,13 @@ +/* global m */ +(function (app) { + "use strict" + + app.ENTER_KEY = 13 + app.ESC_KEY = 27 + + m.route.mode = "hash" + m.route(document.getElementById("todoapp"), "/", { + "/": app, + "/:filter": app + }) +})(this.app || (this.app = {})) diff --git a/bench/app/js/controllers/todo.js b/bench/app/js/controllers/todo.js new file mode 100644 index 00000000..27577689 --- /dev/null +++ b/bench/app/js/controllers/todo.js @@ -0,0 +1,111 @@ +/* global m */ +(function (app) { + "use strict" + + app.controller = function () { + /* eslint-disable no-warning-comments */ + // Todo collection, update with props + /* eslint-enable no-warning-comments */ + this.list = app.storage.get().map(function (item) { + return new app.Todo(item) + }) + + // Temp title placeholder + this.title = m.prop("") + + /* eslint-disable no-warning-comments */ + // Todo list filter + /* eslint-enable no-warning-comments */ + this.filter = m.prop(m.route.param("filter") || "") + + this.add = function () { + var title = this.title().trim() + if (title) { + this.list.push(new app.Todo({title: title})) + app.storage.put(this.list) + } + this.title("") + } + + this.isVisible = function (todo) { + switch (this.filter()) { + case "active": return !todo.completed() + case "completed": return todo.completed() + default: return true + } + } + + this.complete = function (todo) { + if (todo.completed()) { + todo.completed(false) + } else { + todo.completed(true) + } + app.storage.put(this.list) + } + + this.edit = function (todo) { + todo.previousTitle = todo.title() + todo.editing(true) + } + + this.doneEditing = function (todo, index) { + todo.editing(false) + todo.title(todo.title().trim()) + if (!todo.title()) { + this.list.splice(index, 1) + } + app.storage.put(this.list) + } + + this.cancelEditing = function (todo) { + todo.title(todo.previousTitle) + todo.editing(false) + } + + this.clearTitle = function () { + this.title("") + } + + this.remove = function (key) { + this.list.splice(key, 1) + app.storage.put(this.list) + } + + this.clearCompleted = function () { + for (var i = this.list.length - 1; i >= 0; i--) { + if (this.list[i].completed()) { + this.list.splice(i, 1) + } + } + app.storage.put(this.list) + } + + this.amountCompleted = function () { + var amount = 0 + for (var i = 0; i < this.list.length; i++) { + if (this.list[i].completed()) { + amount++ + } + } + return amount + } + + this.allCompleted = function () { + for (var i = 0; i < this.list.length; i++) { + if (!this.list[i].completed()) { + return false + } + } + return true + } + + this.completeAll = function () { + var allCompleted = this.allCompleted() + for (var i = 0; i < this.list.length; i++) { + this.list[i].completed(!allCompleted) + } + app.storage.put(this.list) + } + } +})(this.app || (this.app = {})) diff --git a/bench/app/js/models/storage.js b/bench/app/js/models/storage.js new file mode 100644 index 00000000..0e867e9a --- /dev/null +++ b/bench/app/js/models/storage.js @@ -0,0 +1,14 @@ +(function (app) { + "use strict" + + var STORAGE_ID = "todos-mithril" + + app.storage = { + get: function () { + return JSON.parse(localStorage.getItem(STORAGE_ID) || "[]") + }, + put: function (todos) { + localStorage.setItem(STORAGE_ID, JSON.stringify(todos)) + } + } +})(this.app || (this.app = {})) diff --git a/bench/app/js/models/todo.js b/bench/app/js/models/todo.js new file mode 100644 index 00000000..77af9ba2 --- /dev/null +++ b/bench/app/js/models/todo.js @@ -0,0 +1,12 @@ +/* global m */ + +(function (app) { + "use strict" + + // Todo Model + app.Todo = function (data) { + this.title = m.prop(data.title) + this.completed = m.prop(data.completed || false) + this.editing = m.prop(data.editing || false) + } +})(this.app || (this.app = {})) diff --git a/bench/app/js/views/footer-view.js b/bench/app/js/views/footer-view.js new file mode 100644 index 00000000..25f07f1c --- /dev/null +++ b/bench/app/js/views/footer-view.js @@ -0,0 +1,40 @@ +/* global m */ +(function (app) { + "use strict" + + var filter = { + view: function (_, ctrl, expected, name, href) { + return m("li", [ + m("a", { + href: href, + config: m.route, + class: ctrl.filter() === expected ? "selected" : "" + }, name) + ]) + } + } + + app.footer = { + view: function (_, ctrl) { + var amountCompleted = ctrl.amountCompleted() + var amountActive = ctrl.list.length - amountCompleted + + return m("footer#footer", [ + m("span#todo-count", [ + m("strong", amountActive), " item" + + (amountActive !== 1 ? "s" : "") + " left" + ]), + m("ul#filters", [ + m(filter, ctrl, "", "All", "/"), + m(filter, ctrl, "active", "Active", "/active"), + m(filter, ctrl, "completed", "Completed", "/completed") + ]), + ctrl.amountCompleted() ? m("button#clear-completed", { + onclick: function () { + ctrl.clearCompleted() + } + }, "Clear completed") : null + ]) + } + } +})(this.app || (this.app = {})) diff --git a/bench/app/js/views/main-view.js b/bench/app/js/views/main-view.js new file mode 100644 index 00000000..9dfd2667 --- /dev/null +++ b/bench/app/js/views/main-view.js @@ -0,0 +1,119 @@ +/* global m */ +(function (app) { + "use strict" + + // View utility + app.watchInput = function (onenter, onescape) { + return function (e) { + if (e.keyCode === app.ENTER_KEY) { + onenter() + } else if (e.keyCode === app.ESC_KEY) { + onescape() + } + } + } + + var header = { + controller: function () { + this.focused = false + }, + + view: function (ctrl, parentCtrl) { + return m("header#header", [ + m("h1", "todos"), + m('input#new-todo[placeholder="What needs to be done?"]', { + onkeyup: app.watchInput( + function () { + parentCtrl.add() + }, + function () { + parentCtrl.clearTitle() + }), + value: parentCtrl.title(), + oninput: m.withAttr("value", parentCtrl.title), + config: function (element) { + if (!ctrl.focused) { + element.focus() + ctrl.focused = true + } + } + }) + ]) + } + } + + var todo = { + view: function (_, parentCtrl, task, index) { + return m("li", { + class: (task.completed() ? "completed" : "") + + (task.editing() ? " editing" : "") + }, [ + m(".view", [ + m("input.toggle[type=checkbox]", { + onclick: m.withAttr("checked", function () { + parentCtrl.complete(task) + }), + checked: task.completed() + }), + m("label", { + ondblclick: function () { + parentCtrl.edit(task) + } + }, task.title()), + m("button.destroy", { + onclick: function () { + parentCtrl.remove(index) + } + }) + ]), + m("input.edit", { + value: task.title(), + onkeyup: app.watchInput( + function () { + parentCtrl.doneEditing(task, index) + }, + function () { + parentCtrl.cancelEditing(task) + }), + oninput: m.withAttr("value", task.title), + config: function (element) { + if (task.editing()) { + element.focus() + element.selectionStart = + element.value.length + } + }, + onblur: function () { + parentCtrl.doneEditing(task, index) + } + }) + ]) + } + } + + app.view = function (ctrl) { + return m("div", [ + m(header, ctrl), + m("section#main", { + style: { + display: ctrl.list.length ? "" : "none" + } + }, [ + m("input#toggle-all[type=checkbox]", { + onclick: ctrl.completeAll.bind(ctrl), + checked: ctrl.allCompleted() + }), + m("ul#todo-list", [ + ctrl.list + .filter(function () { + return ctrl.isVisible() + }) + .map(function (task, index) { + return m(todo, ctrl, task, index) + }) + ]) + ]), + ctrl.list.length === 0 ? "" : m(app.footer, ctrl) + ]) + } +})(this.app || (this.app = {})) diff --git a/bench/app/package.json b/bench/app/package.json new file mode 100644 index 00000000..3b1e70fd --- /dev/null +++ b/bench/app/package.json @@ -0,0 +1,7 @@ +{ + "private": true, + "dependencies": { + "todomvc-common": "^1.0.1", + "todomvc-app-css": "^1.0.1" + } +} diff --git a/bench/app/readme.md b/bench/app/readme.md new file mode 100644 index 00000000..76a71d43 --- /dev/null +++ b/bench/app/readme.md @@ -0,0 +1,18 @@ +# Mithril TodoMVC Example + +> [Mithril](http://lhorie.github.io/mithril/) is a client-side MVC framework - a tool to organize code in a way that is easy to think about and to maintain. + +> _[Mithril - lhorie.github.io/mithril/](http://lhorie.github.io/mithril/)_ + +## Learning Mithril + +The [Mithril website](http://lhorie.github.io/mithril/getting-started.html) is a great resource for getting started. + +Here are some links you may find helpful: + +* [Official Documentation](http://lhorie.github.io/mithril/mithril.html) + +_If you have other helpful links to share, or find any of the links above no longer work, please [let us know](https://github.com/tastejs/todomvc/issues)._ + +## Credit +This TodoMVC application was created by [taylorhakes](https://github.com/taylorhakes). \ No newline at end of file diff --git a/bench/index.html b/bench/index.html new file mode 100644 index 00000000..6f399b3d --- /dev/null +++ b/bench/index.html @@ -0,0 +1,81 @@ + +TodoMVC Benchmark + + + + + + +
+
+
+
+ diff --git a/bench/resources/benchmark-runner.js b/bench/resources/benchmark-runner.js new file mode 100644 index 00000000..e3d2e908 --- /dev/null +++ b/bench/resources/benchmark-runner.js @@ -0,0 +1,268 @@ +(function (global) { // eslint-disable-line max-statements + "use strict" + var m = global.m + + window.onhashchange = function () { + location.reload() + } + + global.BenchmarkTestStep = BenchmarkTestStep + function BenchmarkTestStep(name, run) { + this.name = name + this.run = run + } + + global.BenchmarkRunner = BenchmarkRunner + function BenchmarkRunner(suites, client) { + this._suites = suites + this._prepareReturnValue = null + this._measuredValues = {} + this._client = client + } + + BenchmarkRunner.prototype.waitForElement = function (selector) { + var deferred = m.deferred() + var contentDocument = this._frame.contentDocument + + function resolveIfReady() { + var element = contentDocument.querySelector(selector) + if (element) { + return deferred.resolve(element) + } + setTimeout(resolveIfReady, 50) + } + + resolveIfReady() + return deferred.promise + } + + BenchmarkRunner.prototype._removeFrame = function () { + if (this._frame) { + this._frame.parentNode.removeChild(this._frame) + this._frame = null + } + } + + BenchmarkRunner.prototype._appendFrame = function () { + var frame = document.createElement("iframe") + frame.style.width = "800px" + frame.style.height = "600px" + document.body.appendChild(frame) + this._frame = frame + return frame + } + + BenchmarkRunner.prototype._waitAndWarmUp = function () { + var startTime = Date.now() + + function Fibonacci(n) { + if (Date.now() - startTime > 100) return + else if (n <= 0) return 0 + else if (n === 1) return 1 + else return Fibonacci(n - 2) + Fibonacci(n - 1) + } + + var deferred = m.deferred() + setTimeout(function () { + Fibonacci(100) + deferred.resolve() + }, 200) + return deferred.promise + } + + var now = window.performance && window.performance.now ? + function () { return window.performance.now() } : + function () { return +new Date() } + + function logResults(document, expected, suite, test, callback) { + var count = document.querySelectorAll(".view").length + if (count !== expected) { + console.error([ // eslint-disable-line no-console + suite.name, + test.name, + "expected", + expected, + "got", + count + ]) + callback(NaN, NaN) + } + } + + // This function must be as simple as possible, so it doesn't interfere + // with reading the times + BenchmarkRunner.prototype._runTest = function ( + suite, + test, + prepareReturnValue, + callback + ) { + var testFunction = test.run + + var window = this._frame.contentWindow + var document = this._frame.contentDocument + var expected = this._client.numberOfItemsToAdd + + var startTime = now() + testFunction(prepareReturnValue, window, document) + var endTime = now() + var syncTime = endTime - startTime + + startTime = now() + setTimeout(function () { + setTimeout(function () { + var endTime = now() + + // if the DOM count is wrong after a test, don't report its + // results. + if (/Adding|Completing/.test(test.name)) { + logResults(document, expected, suite, test, callback) + } + + if (/Deleting/.test(test.name)) { + logResults(document, 0, suite, test, callback) + } + + callback(syncTime, endTime - startTime) + }, 0) + }, 0) + } + + function BenchmarkState(suites) { + this._suites = suites + this._suiteIndex = -1 + this._testIndex = 0 + this.next() + } + + BenchmarkState.prototype.currentSuite = function () { + return this._suites[this._suiteIndex] + } + + BenchmarkState.prototype.currentTest = function () { + var suite = this.currentSuite() + return suite ? suite.tests[this._testIndex] : null + } + + BenchmarkState.prototype.next = function () { + this._testIndex++ + + var suite = this._suites[this._suiteIndex] + if (suite && this._testIndex < suite.tests.length) { + return this + } + + this._testIndex = 0 + + var i = this._suiteIndex + var suites = this._suites + + do { + i++ + } while (i < suites.length && suites[i].disabled) + + this._suiteIndex = i + + return this + } + + BenchmarkState.prototype.isFirstTest = function () { + return !this._testIndex + } + + BenchmarkState.prototype.prepareCurrentSuite = function (runner, frame) { + var suite = this.currentSuite() + var deferred = m.deferred() + frame.onload = function () { + suite.prepare(runner, frame.contentWindow, frame.contentDocument) + .then(function (result) { + deferred.resolve(result) + }, function (err) { + deferred.reject(err) + }) + } + frame.src = suite.url + return deferred.promise + } + + BenchmarkRunner.prototype.step = function (state) { + if (!state) { + state = new BenchmarkState(this._suites) + } + + var suite = state.currentSuite() + if (!suite) { + this._finalize() + var deferred = m.deferred() + deferred.resolve() + return deferred.promise + } + + if (state.isFirstTest()) { + this._masuredValuesForCurrentSuite = {} + var self = this + return state.prepareCurrentSuite(this, this._appendFrame()) + .then(function (prepareReturnValue) { + self._prepareReturnValue = prepareReturnValue + return self._runTestAndRecordResults(state) + }) + } + + return this._runTestAndRecordResults(state) + } + + BenchmarkRunner.prototype._runTestAndRecordResults = function (state) { + var deferred = m.deferred() + var suite = state.currentSuite() + var test = state.currentTest() + + if (this._client && this._client.willRunTest) { + this._client.willRunTest(suite, test) + } + + var self = this + setTimeout(function () { + self._runTest(suite, test, self._prepareReturnValue, + function (syncTime, asyncTime) { + var suiteResults + if (self._measuredValues[suite.name]) { + suiteResults = self._measuredValues[suite.name] + } else { + suiteResults = {tests: {}, total: 0} + } + + self._measuredValues[suite.name] = suiteResults + + suiteResults.tests[test.name] = { + Sync: syncTime, + Async: asyncTime + } + + suiteResults.total += syncTime + asyncTime + + if (self._client && self._client.willRunTest) { + self._client.didRunTest(suite, test) + } + + state.next() + if (state.currentSuite() !== suite) { + self._removeFrame() + } + + deferred.resolve(state) + }) + }, 0) + return deferred.promise + } + + BenchmarkRunner.prototype._finalize = function () { + this._removeFrame() + + if (this._client && this._client.didRunSuites) { + this._client.didRunSuites(this._measuredValues) + } + + // FIXME: This should be done when we start running tests. + this._measuredValues = {} + } +})(this) diff --git a/bench/resources/manager.js b/bench/resources/manager.js new file mode 100644 index 00000000..2b422792 --- /dev/null +++ b/bench/resources/manager.js @@ -0,0 +1,237 @@ +/* global m */ +/* eslint no-console: 0 */ + +(function (global) { // eslint-disable-line max-statements + "use strict" + + var numberOfItemsToAdd = (~~location.hash.slice(1)) || 250 + var runs = [] + var timesRan = 0 + var runButton + + function forOwn(object, f) { + for (var key in object) if ({}.hasOwnProperty.call(object, key)) { + f(object[key], key) + } + } + + var append = Function.call.bind(function append() { + /* eslint-disable no-invalid-this */ + for (var i = 0; i < arguments.length; i++) { + var arg = arguments[i] + if (Array.isArray(arg)) append.apply(this, arg) + else this.appendChild(arg) + } + return this + /* eslint-enable no-invalid-this */ + }) + + function n(type, attrs) { + var el = document.createElement(type) + if (!attrs) return el + forOwn(attrs, function (value, attr) { + if (attr === "style") { + forOwn(value, function (k, v) { el.style[k] = v }) + } else { + el[attr] = value + } + }) + return el + } + + function createButton(text, onclick) { + return n("button", {textContent: text, onclick: onclick}) + } + + function createTest(suite, test) { + return append(n("li"), + append(test.anchor = n("a", {id: suite.name + "-" + test.name}), + document.createTextNode(suite.name + "/" + test.name))) + } + + function createSuiteCheckbox(suite) { + return n("input", { + id: suite.name, + type: "checkbox", + checked: true, + onchange: function () { + suite.disabled = !this.checked + } + }) + } + + function createSuiteLabel(suite) { + return append(n("label", {htmlFor: suite.name}), + document.createTextNode(suite.name + " " + suite.version)) + } + + function createSuite(suite) { + return append(n("li"), + createSuiteCheckbox(suite), + createSuiteLabel(suite), + append(n("ol"), suite.tests.map(createTest.bind(null, suite)))) + } + + function createUIForSuites(suites, onstep, onrun) { + return append(n("nav"), + createButton("Step Tests", onstep), + runButton = createButton("Run All", onrun), + append(n("ol"), suites.map(createSuite))) + } + + function generateResults(measured, timesToRun) { + var result = "" + var total = 0 // FIXME: Compute the total properly. + + forOwn(measured, function (suiteResults, suite) { + forOwn(suiteResults.tests, function (testResults, test) { + forOwn(testResults, function (subtestResults, subtest) { + result += suite + " : " + test + " : " + subtest + ": " + + subtestResults + " ms\n" + }) + }) + result += suite + " : " + suiteResults.total + " ms\n" + total += suiteResults.total + }) + return result + "Run " + (runs.length + 1) + "/" + timesToRun + + " - Total : " + total + " ms\n" + } + + function reportFastest() { + var results = {} + runs.forEach(function (runData) { + forOwn(runData, function (data, key) { + results[key] = Math.min( + results[key] || Infinity, + data.total + ) + }) + }) + return results + } + + global.google.load("visualization", "1", {packages: ["corechart"]}) + function drawChart(results) { + var V = global.google.visualization + + var raw = [] + forOwn(results, function (result, key) { + raw.push([key, Math.round(result), colorify(key)]) + }) + raw.sort(function (a, b) { return a[1] - b[1] }) + raw.unshift(["Project", "Time", {role: "style"}]) + + var runWord = "run" + (runs.length > 1 ? "s" : "") + var title = "Best time in milliseconds over " + runs.length + " " + + runWord + " (lower is better)" + + var view = new V.DataView(V.arrayToDataTable(raw)) + view.setColumns([0, 1, { + calc: "stringify", + sourceColumn: 1, + type: "string", + role: "annotation" + }, 2]) + + document.getElementById("analysis").style.display = "block" + + new V.BarChart(document.getElementById("barchart-values")).draw(view, { + title: "TodoMVC Benchmark", + width: 600, + height: 400, + legend: {position: "none"}, + backgroundColor: "transparent", + hAxis: {title: title}, + min: 0, + max: 1500 + }) + } + + function colorPart(n, pre) { + return Math.max( + 0, + ((n.toLowerCase().charCodeAt(pre % n.length) - 97) / 26 * 255) | 0 + ) + } + + function colorify(n) { + return "rgb(" + + colorPart(n, 3) + ", " + + colorPart(n, 4) + ", " + + colorPart(n, 5) + ")" + } + + function shuffle(ary) { + for (var i = 0; i < ary.length; i++) { + var j = Math.floor(Math.random() * (i + 1)) + var tmp = ary[i] + ary[i] = ary[j] + ary[j] = tmp + } + } + + window.addEventListener("load", function () { + var match = window.location.search.match(/[\?&]r=(\d+)/) + var timesToRun = match ? +(match[1]) : 1 + + var Suites = global.Suites.map(function (suite) { + suite = suite(numberOfItemsToAdd) + suite.disabled = false + return suite + }) + + var runner = new global.BenchmarkRunner(Suites, { + numberOfItemsToAdd: numberOfItemsToAdd, + + willRunTest: function (suite, test) { + if (test.anchor.classList) { + test.anchor.classList.add("running") + } + }, + + didRunTest: function (suite, test) { + if (test.anchor.classList) { + test.anchor.classList.remove("running") + test.anchor.classList.add("ran") + } + }, + + didRunSuites: function (measured) { + var results = generateResults(measured, timesToRun) + if (results) { + console.log(results) + + runs.push(measured) + timesRan++ + if (timesRan >= timesToRun) { + timesRan = 0 + drawChart(reportFastest()) + shuffle(Suites) + } else { + setTimeout(function () { + runButton.click() + }, 0) + } + } + } + }) + + var currentState = m.prop() + function callNextStep(state) { + runner.step(state).then(currentState).then(function (newState) { + if (newState) callNextStep(newState) + }) + } + + // Don't call step while step is already executing. + document.body.appendChild(createUIForSuites(Suites, + function () { + runner.step(currentState()).then(currentState) + }, + function () { + document.getElementById("analysis").style.display = "none" + localStorage.clear() + callNextStep(currentState()) + })) + }) +})(this) diff --git a/bench/resources/tests.js b/bench/resources/tests.js new file mode 100644 index 00000000..872fcfa4 --- /dev/null +++ b/bench/resources/tests.js @@ -0,0 +1,51 @@ +(function (global) { + "use strict" + + var BenchmarkTestStep = global.BenchmarkTestStep + var Suites = global.Suites = [] + + Suites.push(function (numberOfItemsToAdd) { + return { + name: "Mithril (TodoMVC 1.3)", + url: "app/index.html", + version: "next (dev version)", + prepare: function (runner) { + return runner.waitForElement("#new-todo") + .then(function (element) { + element.focus() + return element + }) + }, + tests: [ + new BenchmarkTestStep("Adding" + numberOfItemsToAdd + "Items", + function (newTodo) { + for (var i = 0; i < numberOfItemsToAdd; i++) { + var inputEvent = document.createEvent("Event") + inputEvent.initEvent("input", true, true) + newTodo.value = "Mithril ------- Something to do " + i + newTodo.dispatchEvent(inputEvent) + + var keydownEvent = document.createEvent("Event") + keydownEvent.initEvent("keyup", true, true) + keydownEvent.keyCode = 13 // VK_ENTER + newTodo.dispatchEvent(keydownEvent) + } + }), + new BenchmarkTestStep("CompletingAllItems", + function (newTodo, contentWindow, document) { + var checkboxes = document.getElementsByClassName("toggle") + for (var i = 0; i < checkboxes.length; i++) { + checkboxes[i].click() + } + }), + new BenchmarkTestStep("DeletingAllItems", + function (newTodo, contentWindow, document) { + var buttons = document.getElementsByClassName("destroy") + for (var i = buttons.length - 1; i > -1; i--) { + buttons[i].click() + } + }) + ] + } + }) +})(this) diff --git a/mithril.js b/mithril.js index 2c9de619..bb4e567d 100644 --- a/mithril.js +++ b/mithril.js @@ -1,4 +1,4 @@ -void (function (global, factory) { // eslint-disable-line +;(function (global, factory) { // eslint-disable-line "use strict" /* eslint-disable no-undef */ var m = factory(typeof window !== "undefined" ? window : {}) @@ -37,22 +37,20 @@ void (function (global, factory) { // eslint-disable-line function noop() {} - function forEach(list, f) { - for (var i = 0; i < list.length && !f(list[i], i++);) { - // empty + function forEach(list, f, inst) { + for (var i = 0; i < list.length; i++) { + f.call(inst, list[i], i) } } - function forOwn(obj, f) { + function forOwn(obj, f, inst) { for (var prop in obj) { if (hasOwn.call(obj, prop)) { - if (f(obj[prop], prop)) break + if (f.call(inst, obj[prop], prop)) break } } } - var parser = /(?:(^|#|\.)([^#\.\[\]]+))|(\[.+?\])/g - var attrParser = /\[(.+?)(?:=("|'|)(.*?)\2)?\]/ var voidElements = /^(AREA|BASE|BR|COL|COMMAND|EMBED|HR|IMG|INPUT|KEYGEN|LINK|META|PARAM|SOURCE|TRACK|WBR)$/ // eslint-disable-line max-len // caching commonly used variables @@ -87,23 +85,23 @@ void (function (global, factory) { // eslint-disable-line */ function checkForAttrs(pairs) { - return pairs != null && - isObject(pairs) && + return pairs != null && isObject(pairs) && !("tag" in pairs || "view" in pairs || "subtree" in pairs) } function parseSelector(tag, cell) { var classes = [] + var parser = /(?:(^|#|\.)([^#\.\[\]]+))|(\[.+?\])/g var match while ((match = parser.exec(tag)) != null) { - if (match[1] === "" && match[2]) { + if (match[1] === "" && match[2] != null) { cell.tag = match[2] } else if (match[1] === "#") { cell.attrs.id = match[2] } else if (match[1] === ".") { classes.push(match[2]) } else if (match[3][0] === "[") { - var pair = attrParser.exec(match[3]) + var pair = /\[(.+?)(?:=("|'|)(.*?)\2)?\]/.exec(match[3]) cell.attrs[pair[1]] = pair[3] || (pair[2] ? "" : true) } } @@ -111,34 +109,64 @@ void (function (global, factory) { // eslint-disable-line return classes } - function getChildrenFromList(hasAttrs, args) { - var children = hasAttrs ? args.slice(1) : args - if (children.length === 1 && isArray(children[0])) { - return children[0] - } else { - return children - } - } - - function assignAttrs(cell, attrs, classAttr, classes) { - forOwn(attrs, function (value, attr) { - if (attr === classAttr && - attrs[attr] != null && - attrs[attr] !== "") { - classes.push(attrs[attr]) - - // create key in correct iteration order - cell.attrs[attr] = "" - } else { - cell.attrs[attr] = attrs[attr] + function assignAttrs(target, attrs, classAttr, classes) { + var hasClass = false + if (hasOwn.call(attrs, classAttr)) { + var value = attrs[classAttr] + if (value != null && value !== "") { + hasClass = true + classes.push(value) } + } + + forOwn(attrs, function (value, attr) { + target[attr] = attr === classAttr && hasClass ? "" : value }) if (classes.length) { - cell.attrs[classAttr] = classes.join(" ") + target[classAttr] = classes.join(" ") } } + function parameterize(component) { + var args = [] + for (var i = 1; i < arguments.length; i++) { + args.push(arguments[i]) + } + + var originalCtrl = component.controller || noop + + function Ctrl() { + return originalCtrl.apply(this, args) || this + } + + if (originalCtrl !== noop) { + Ctrl.prototype = originalCtrl.prototype + } + + var originalView = component.view || noop + + function view(ctrl) { + var rest = [ctrl].concat(args) + for (var i = 1; i < arguments.length; i++) { + rest.push(arguments[i]) + } + + return originalView.apply(component, rest) + } + + view.$original = originalView + var output = {controller: Ctrl, view: view} + + if (args[0] && args[0].key != null) { + output.attrs = {key: args[0].key} + } + + return output + } + + m.component = parameterize + /** * @param {Tag} The DOM node tag * @param {Object=[]} optional key-value pairs to be mapped to DOM attrs @@ -146,34 +174,60 @@ void (function (global, factory) { // eslint-disable-line * or splat (optional) */ function m(tag, pairs) { - for (var args = [], i = 1; i < arguments.length; i++) { - args[i - 1] = arguments[i] - } - - if (isObject(tag)) return parameterize(tag, args) - var hasAttrs = checkForAttrs(pairs) - var attrs = hasAttrs ? pairs : {} - var classAttr = "class" in attrs ? "class" : "className" - var cell = {tag: "div", attrs: {}} + // The arguments are passed directly like this to delay array + // allocation. + if (isObject(tag)) return parameterize.apply(null, arguments) if (!isString(tag)) { - throw new Error("selector in m(selector, attrs, children) should " + - "be a string") + throw new TypeError("selector in m(selector, attrs, children) " + + "should be a string") } - var classes = parseSelector(tag, cell) - cell.children = getChildrenFromList(hasAttrs, args) + // Degenerate case frequently trips people up. Check for it here so that + // people know it doesn't work. + if (!tag) { + throw new TypeError("selector cannot be an empty string") + } - assignAttrs(cell, attrs, classAttr, classes) + var hasAttrs = checkForAttrs(pairs) + + var args = [] + for (var i = hasAttrs ? 2 : 1; i < arguments.length; i++) { + args.push(arguments[i]) + } + + var children + + if (args.length === 1 && isArray(args[0])) { + children = args[0] + } else { + children = args + } + + var cell = { + tag: "div", + attrs: {}, + children: children + } + + assignAttrs( + cell.attrs, + hasAttrs ? pairs : {}, + hasAttrs && "class" in pairs ? "class" : "className", + parseSelector(tag, cell) + ) return cell } - function forKeys(list, f) { - forEach(list, function (attrs, i) { + function forKeys(list, f, inst) { + for (var i = 0; i < list.length; i++) { + var attrs = list[i] attrs = attrs && attrs.attrs - return attrs && attrs.key != null && f(attrs, i) - }) + if (attrs && attrs.key != null && f.call(inst, attrs, i)) { + break + } + } } // This function was causing deopts in Chrome. @@ -191,17 +245,6 @@ void (function (global, factory) { // eslint-disable-line return "" } - // This function was causing deopts in Chrome. - function injectTextNode(parent, first, index, data) { - try { - insertNode(parent, first, index) - first.nodeValue = data - } catch (e) { - // IE erroneously throws error when appending an empty text node - // after a null - } - } - function flatten(list) { // recursively flatten array for (var i = 0; i < list.length; i++) { @@ -220,111 +263,129 @@ void (function (global, factory) { // eslint-disable-line parent.insertBefore(node, parent.childNodes[index] || null) } + // the below recursively manages creation/diffing/removal of DOM elements + // based on comparison between `data` and `cached` + // + // the diff algorithm can be summarized as this: + // 1) compare `data` and `cached` + // 2) if they are different, copy `data` to `cached` and update the DOM + // based on what the difference is + // 3) recursively apply this algorithm for every array and for the + // children of every virtual element + // + // the `cached` data structure is essentially the same as the previous + // redraw's `data` data structure, with a few additions: + // - `cached` always has a property called `nodes`, which is a list of + // DOM elements that correspond to the data represented by the + // respective virtual element + // - in order to support attaching `nodes` as a property of `cached`, + // `cached` is *always* a non-primitive object, i.e. if the data was + // a string, then cached is a String instance. If data was `null` or + // `undefined`, cached is `new String("")` + // - `cached also has a `configContext` property, which is the state + // storage object exposed by config(element, isInitialized, context) + // - when `cached` is an Object, it represents a virtual element; when + // it's an Array, it represents a list of elements; when it's a + // String, Number or Boolean, it represents a text node + // + // `parentElement` is a DOM element used for W3C DOM API calls + // `parentTag` is only used for handling a corner case for textarea + // values + // `parentCache` is used to remove nodes in some multi-node cases + // `parentIndex` and `index` are used to figure out the offset of nodes. + // They're artifacts from before arrays started being flattened and are + // likely refactorable + // `data` and `cached` are, respectively, the new and old nodes being + // diffed + // `shouldReattach` is a flag indicating whether a parent node was + // recreated (if so, and if this node is reused, then this node must + // reattach itself to the new parent) + // `editable` is a flag that indicates whether an ancestor is + // contenteditable + // `namespace` indicates the closest HTML namespace as it cascades down + // from an ancestor + // `configs` is a list of config functions to run after the topmost + // `build` call finishes running + // + // there's logic that relies on the assumption that null and undefined + // data are equivalent to empty strings + // - this prevents lifecycle surprises from procedural helpers that mix + // implicit and explicit return statements (e.g. + // function foo() {if (cond) return m("div")} + // - it simplifies diffing code + + function Builder( + parentElement, + parentTag, + parentCache, + parentIndex, + data, + cached, + shouldReattach, + index, + editable, + namespace, + configs + ) { + this.parentElement = parentElement + this.parentTag = parentTag + this.parentCache = parentCache + this.parentIndex = parentIndex + this.data = data + this.cached = cached + this.shouldReattach = shouldReattach + this.index = index + this.editable = editable + this.namespace = namespace + this.configs = configs + } + + Builder.prototype.build = function () { + this.data = dataToString(this.data) + if (this.data.subtree === "retain") return this.cached + this.makeCache() + + if (isArray(this.data)) { + return this.buildArray() + } else if (this.data != null && isObject(this.data)) { + return this.buildObject() + } else if (isFunction(this.data)) { + return this.cached + } else { + return this.handleTextNode() + } + } + + Builder.prototype.makeCache = function () { + if (this.cached != null) { + if (type.call(this.cached) === type.call(this.data)) { + return + } + + if (this.parentCache && this.parentCache.nodes) { + var offset = this.index - this.parentIndex + var end = offset + + (isArray(this.data) ? this.data : this.cached.nodes).length + + clear( + this.parentCache.nodes.slice(offset, end), + this.parentCache.slice(offset, end)) + } else if (this.cached.nodes) { + clear(this.cached.nodes, this.cached) + } + } + + this.cached = new this.data.constructor() + // if constructor creates a virtual dom element, use a blank object as + // the base cached node instead of copying the virtual el (#277) + if (this.cached.tag) this.cached = {} + this.cached.nodes = [] + } + var DELETION = 1 var INSERTION = 2 var MOVE = 3 - function handleKeysDiffer(data, existing, cached, parent) { - forKeys(data, function (key, i) { - key = key.key - if (existing[key]) { - existing[key] = { - action: MOVE, - index: i, - from: existing[key].index, - element: cached.nodes[existing[key].index] || - $document.createElement("div") - } - } else { - existing[key] = {action: INSERTION, index: i} - } - }) - - var actions = [] - - forOwn(existing, function (value) { - actions.push(value) - }) - - var changes = actions.sort(sortChanges) - var newCached = new Array(cached.length) - newCached.nodes = cached.nodes.slice() - - forEach(changes, function (change) { - var index = change.index - - switch (change.action) { - case DELETION: - clear(cached[index].nodes, cached[index]) - newCached.splice(index, 1) - break - - case INSERTION: - var dummy = $document.createElement("div") - dummy.key = data[index].attrs.key - insertNode(parent, dummy, index) - newCached.splice(index, 0, { - attrs: {key: data[index].attrs.key}, - nodes: [dummy] - }) - newCached.nodes[index] = dummy - break - - case MOVE: - var changeElement = change.element - var maybeChanged = parent.childNodes[index] - if (maybeChanged !== changeElement && changeElement !== null) { - parent.insertBefore(changeElement, maybeChanged || null) - } - newCached[index] = cached[change.from] - newCached.nodes[index] = changeElement - } - }) - - return newCached - } - - function diffKeys(data, cached, existing, parentElement) { - var keysDiffer = data.length !== cached.length - - if (!keysDiffer) { - forKeys(data, function (attrs, i) { - var cachedCell = cached[i] - return keysDiffer = cachedCell && - cachedCell.attrs && - cachedCell.attrs.key !== attrs.key - }) - } - - if (keysDiffer) { - return handleKeysDiffer(data, existing, cached, parentElement) - } else { - return cached - } - } - - // diffs the array itself - function diffArray(data, cached, nodes) { - // update the list of DOM nodes by collecting the nodes from each item - forEach(data, function (_, i) { - if (cached[i] != null) nodes.push.apply(nodes, cached[i].nodes) - }) - - // remove items from the end of the array if the new array is shorter - // than the old one. if errors ever happen here, the issue is most - // likely a bug in the construction of the `cached` data structure - // somewhere earlier in the program - forEach(cached.nodes, function (node, i) { - if (node.parentNode != null && nodes.indexOf(node) < 0) { - clear([node], [cached[i]]) - } - }) - - if (data.length < cached.length) cached.length = data.length - - cached.nodes = nodes - } - function buildArrayKeys(data) { var guid = 0 forKeys(data, function () { @@ -338,6 +399,317 @@ void (function (global, factory) { // eslint-disable-line }) } + Builder.prototype.buildArrayChild = function (child, cached, count) { + return new Builder( + this.parentElement, + this.parentTag, + this.cached, + this.index, + child, + cached, + this.shouldReattach, + this.index + count || count, + this.editable, + this.namespace, + this.configs + ).build() + } + + Builder.prototype.buildArray = function () { + this.data = flatten(this.data) + var nodes = [] + var intact = this.cached.length === this.data.length + var subArrayCount = 0 + + // keys algorithm: + // sort elements without recreating them if keys are present + // + // 1) create a map of all existing keys, and mark all for deletion + // 2) add new keys to map and mark them for addition + // 3) if key exists in new list, change action from deletion to a move + // 4) for each key, handle its corresponding action as marked in + // previous steps + var existing = {} + var shouldMaintainIdentities = false + forKeys(this.cached, function (attrs, i) { + shouldMaintainIdentities = true + existing[attrs.key] = { + action: DELETION, + index: i + } + }) + + buildArrayKeys(this.data) + if (shouldMaintainIdentities) { + this.diffKeys(existing) + } + // end key algorithm + + // don't change: faster than forEach + var cacheCount = 0 + for (var i = 0, len = this.data.length; i < len; i++) { + // diff each item in the array + var item = this.buildArrayChild( + this.data[i], + this.cached[cacheCount], + subArrayCount + ) + + if (item !== undefined) { + intact = intact && item.nodes.intact + subArrayCount += getSubArrayCount(item) + this.cached[cacheCount++] = item + } + } + + if (!intact) this.diffArray(nodes) + + return this.cached + } + + Builder.prototype.diffKeys = function (existing) { + var keysDiffer = this.data.length !== this.cached.length + + if (!keysDiffer) { + forKeys(this.data, function (attrs, i) { + var cachedCell = this[i] // eslint-disable-line no-invalid-this + return keysDiffer = cachedCell && + cachedCell.attrs && + cachedCell.attrs.key !== attrs.key + }, this.cached) + } + + if (keysDiffer) { + this.handleKeysDiffer(existing) + } + } + + // Simple `this` helper + function thisPush(value) { + this.push(value) // eslint-disable-line no-invalid-this + } + + Builder.prototype.handleKeysDiffer = function (existing) { + forKeys(this.data, function (key, i) { + key = key.key + if (existing[key]) { + existing[key] = { + action: MOVE, + index: i, + from: existing[key].index, + element: this[existing[key].index] || // eslint-disable-line + $document.createElement("div") + } + } else { + existing[key] = { + action: INSERTION, + index: i + } + } + }, this.cached.nodes) + + var actions = [] + forOwn(existing, thisPush, actions) + + var changes = actions.sort(sortChanges) + var newCached = new Array(this.cached.length) + newCached.nodes = this.cached.nodes.slice() + + forEach(changes, function (change) { + /* eslint-disable no-invalid-this */ + var index = change.index + + switch (change.action) { + case DELETION: + clear(this.cached[index].nodes, this.cached[index]) + newCached.splice(index, 1) + break + + case INSERTION: + var dummy = $document.createElement("div") + dummy.key = this.data[index].attrs.key + insertNode(this.parentElement, dummy, index) + newCached.splice(index, 0, { + attrs: {key: this.data[index].attrs.key}, + nodes: [dummy] + }) + newCached.nodes[index] = dummy + break + + case MOVE: + var changeElement = change.element + var maybeChanged = this.parentElement.childNodes[index] + if (maybeChanged !== changeElement && changeElement !== null) { + this.parentElement.insertBefore( + changeElement, + maybeChanged || null + ) + } + newCached[index] = this.cached[change.from] + newCached.nodes[index] = changeElement + } + /* eslint-enable no-invalid-this */ + }, this) + + this.cached = newCached + } + + // diffs the array itself + Builder.prototype.diffArray = function (nodes) { + // update the list of DOM nodes by collecting the nodes from each item + for (var i = 0; i < this.data.length; i++) { + var cached = this.cached[i] + if (cached != null) { + nodes.push.apply(nodes, cached.nodes) + } + } + + // remove items from the end of the array if the new array is shorter + // than the old one. if errors ever happen here, the issue is most + // likely a bug in the construction of the `cached` data structure + // somewhere earlier in the program + forEach(this.cached.nodes, function (node, i) { + /* eslint-disable no-invalid-this */ + if (node.parentNode != null && nodes.indexOf(node) < 0) { + clear([node], [this[i]]) + } + /* eslint-enable no-invalid-this */ + }, this.cached) + + if (this.data.length < this.cached.length) { + this.cached.length = this.data.length + } + + this.cached.nodes = nodes + } + + Builder.prototype.initAttrs = function () { + var dataAttrs = this.data.attrs = this.data.attrs || {} + this.cached.attrs = this.cached.attrs || {} + + var dataAttrKeys = Object.keys(this.data.attrs) + this.maybeRecreateObject(dataAttrKeys) + + return dataAttrKeys.length > +("key" in dataAttrs) + } + + Builder.prototype.buildObject = function () { + var views = [] + var controllers = [] + + this.markViews(views, controllers) + + if (!this.data.tag && controllers.length) { + throw new Error("Component template must return a virtual " + + "element, not an array, string, etc.") + } + + var hasKeys = this.initAttrs() + + if (isString(this.data.tag)) { + return new ObjectBuilder( + this, + hasKeys, + views, + controllers + ).build() + } + } + + Builder.prototype.markViews = function (views, controllers) { + var cached = this.cached && this.cached.controllers + while (this.data.view != null) { + this.checkView(cached, controllers, views) + } + } + + var forcing = false + var pendingRequests = 0 + + Builder.prototype.checkView = function (cached, controllers, views) { + var view = this.data.view.$original || this.data.view + var controller = getController( + this.cached.views, + view, + cached, + this.data.controller + ) + + // Faster to coerce to number and check for NaN + var key = +(this.data && this.data.attrs && this.data.attrs.key) + + if (pendingRequests === 0 || forcing || + cached && cached.indexOf(controller) > -1) { + this.data = this.data.view(controller) + } else { + this.data = {tag: "placeholder"} + } + + if (this.data.subtree === "retain") return this.cached + if (key === key) { // eslint-disable-line no-self-compare + (this.data.attrs = this.data.attrs || {}).key = key + } + updateLists(views, controllers, view, controller) + } + + var unloaders = [] + + function updateLists(views, controllers, view, controller) { + views.push(view) + var idx = controllers.push(controller) - 1 + unloaders[idx] = { + views: views, + view: view, + controller: controller, + controllers: controllers, + handler: function (ev) { + var i = this.controllers.indexOf(this.controller) + this.controllers.splice(i, 1) + i = this.views.indexOf(this.view) + this.views.splice(i, 1) + var unload = this.controller && this.controller.onunload + if (type.call(unload) === "[object Function]") { + this.controller.onunload(ev) + } + } + } + } + + function getController(views, view, cached, controller) { + var index = m.redraw.strategy() === "diff" && views ? + views.indexOf(view) : + -1 + + if (index > -1) { + return cached[index] + } else if (typeof controller === "function") { + return new controller() + } else { + return {} + } + } + + function unloadSingleController(controller) { + if (controller.unload) { + controller.onunload({preventDefault: noop}) + } + } + + Builder.prototype.maybeRecreateObject = function (dataAttrKeys) { + // if an element is different enough from the one in cache, recreate it + if (this.elemIsDifferentEnough(dataAttrKeys)) { + if (this.cached.nodes.length) clear(this.cached.nodes) + if (this.cached.configContext && + isFunction(this.cached.configContext.onunload)) { + this.cached.configContext.onunload() + } + + if (this.cached.controllers) { + forEach(this.cached.controllers, unloadSingleController) + } + } + } + // shallow array compare, sorts function arraySortCompare(a, b) { a.sort() @@ -350,7 +722,9 @@ void (function (global, factory) { // eslint-disable-line return true } - function elemIsDifferentEnough(data, cached, dataAttrKeys) { + Builder.prototype.elemIsDifferentEnough = function (dataAttrKeys) { + var data = this.data + var cached = this.cached if (data.tag !== cached.tag) return true if (!arraySortCompare(dataAttrKeys, Object.keys(cached.attrs))) { return true @@ -360,70 +734,174 @@ void (function (global, factory) { // eslint-disable-line if (data.attrs.key !== cached.attrs.key) return true if (m.redraw.strategy() === "all") { - return !(cached.configContext && - cached.configContext.retain === true) + return !cached.configContext || cached.configContext.retain !== true } else if (m.redraw.strategy() === "diff") { - return cached.configContext && - cached.configContext.retain === false + return cached.configContext && cached.configContext.retain === false + } else { + return false } } - function maybeRecreateObject(data, cached, dataAttrKeys) { - // if an element is different enough from the one in cache, recreate it - if (elemIsDifferentEnough(data, cached, dataAttrKeys)) { - if (cached.nodes.length) clear(cached.nodes) - if (cached.configContext && - isFunction(cached.configContext.onunload)) { - cached.configContext.onunload() - } + function getObjectNamespace(builder) { + var data = builder.data - if (cached.controllers) { - forEach(cached.controllers, function (controller) { - if (controller.unload) { - controller.onunload({preventDefault: noop}) - } - }) - } - } - } - - function getObjectNamespace(data, namespace) { return data.attrs.xmlns ? data.attrs.xmlns : data.tag === "svg" ? "http://www.w3.org/2000/svg" : data.tag === "math" ? "http://www.w3.org/1998/Math/MathML" : - namespace + builder.namespace } - var pendingRequests = 0 - m.startComputation = function () { pendingRequests++ } - m.endComputation = function () { - if (pendingRequests > 1) { - pendingRequests-- + function ObjectBuilder(builder, hasKeys, views, controllers) { + this.builder = builder + this.hasKeys = hasKeys + this.views = views + this.controllers = controllers + this.namespace = getObjectNamespace(builder) + } + + ObjectBuilder.prototype.buildNewNode = function () { + var node = this.createNode() + this.builder.cached = this.reconstruct( + node, + this.createAttrs(node), + this.buildChildren(node) + ) + return node + } + + ObjectBuilder.prototype.build = function () { + var builder = this.builder + var isNew = builder.cached.nodes.length === 0 + var node = isNew ? this.buildNewNode() : this.buildUpdatedNode() + if (isNew || builder.shouldReattach && node != null) { + insertNode(builder.parentElement, node, builder.index) + } + builder.scheduleConfigs(node, isNew) + return builder.cached + } + + ObjectBuilder.prototype.createNode = function () { + var data = this.builder.data + if (this.namespace === undefined) { + if (data.attrs.is) { + return $document.createElement(data.tag, data.attrs.is) + } else { + return $document.createElement(data.tag) + } + } else if (data.attrs.is) { + return $document.createElementNS(this.namespace, data.tag, + data.attrs.is) } else { - pendingRequests = 0 - m.redraw() + return $document.createElementNS(this.namespace, data.tag) } } - function unloadCachedControllers(cached, views, controllers) { - if (controllers.length) { - cached.views = views - cached.controllers = controllers - forEach(controllers, function (controller) { - if (controller.onunload && controller.onunload.$old) { - controller.onunload = controller.onunload.$old - } - - if (pendingRequests && controller.onunload) { - var onunload = controller.onunload - controller.onunload = noop - controller.onunload.$old = onunload - } - }) + ObjectBuilder.prototype.createAttrs = function (node) { + var data = this.builder.data + if (this.hasKeys) { + return setAttributes(node, data.tag, data.attrs, {}, this.namespace) + } else { + return data.attrs } } - function scheduleConfigsToBeCalled(configs, data, node, isNew, cached) { + ObjectBuilder.prototype.makeChild = function (node, shouldReattach) { + var builder = this.builder + return new Builder( + node, + builder.data.tag, + undefined, + undefined, + builder.data.children, + builder.cached.children, + shouldReattach, + 0, + builder.data.attrs.contenteditable ? node : builder.editable, + this.namespace, + builder.configs + ).build() + } + + ObjectBuilder.prototype.buildChildren = function (node) { + var data = this.builder.data + if (data.children != null && data.children.length !== 0) { + return this.makeChild(node, true) + } else { + return data.children + } + } + + ObjectBuilder.prototype.reconstruct = function (node, attrs, children) { + var data = this.builder.data + var cached = { + tag: data.tag, + attrs: attrs, + children: children, + nodes: [node] + } + + this.unloadCachedControllers(cached) + + if (cached.children && !cached.children.nodes) { + cached.children.nodes = [] + } + + // edge case: setting value on