diff --git a/.deploy.enc b/.deploy.enc new file mode 100644 index 00000000..b22144e3 Binary files /dev/null and b/.deploy.enc differ diff --git a/.travis.yml b/.travis.yml index 2b42d205..5cac2607 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,43 @@ +sudo: false + language: node_js node_js: - - stable +- node -sudo: false +cache: + directories: + - node_modules + +# Custom install step so the travis scripts don't need to be in package.json +install: + - npm install + - npm install @alrra/travis-scripts@^3.0.1 + +# After a successful build create bundles & commit back to the repo +after_success: + - | + + # Only want to commit things on commits to $BRANCH + if [ "$TRAVIS_EVENT_TYPE" == "pull_request" ] || [ "$TRAVIS_BRANCH" != "$BRANCH" ]; then + echo "Artifacts only built on $BRANCH" + exit 0 + fi + + # Set up SSH environment + $(npm bin)/set-up-ssh --key "$encrypted_8b86e0359d64_key" \ + --iv "$encrypted_8b86e0359d64_iv" \ + --path-encrypted-key "./.deploy.enc" + + # Build & commit changes + $(npm bin)/commit-changes --commands "npm run build" \ + --commit-message "Bundled output for commit $TRAVIS_COMMIT [skip ci]" \ + --branch "$BRANCH" + +env: + global: + # Restrict the branch this will activate on + - BRANCH=rewrite + + # Set up GH_USER_EMAIL & GH_USER_NAME env variables used by travis-scripts package + - secure: Xvqvm3+PvJu/rs3jl/NNn0RWLkkLkIoPHiL0GCfVRaywgjCYVN02g54NVvIDaOfybqPmu9E6PJFVs92vhF34NMFQHf4EWskynusIGV271R2BV0i+OJBfLMuLgiwm6zRn7/Zw4JvWIUGEwcnlz0qxbqdHsS0SOR3fIkFzePickW0= + - secure: Rf/ldEO9d4vItJhe6EmqWpFAyCARzoCb422nHnjr1hYJknnwIXpgyZ1C/7On/9o7rWPPf+8WcHC/rgjK2rthKCldzdG5I60LfWSNzap9lk3Aa4TpSCoDBuEp7JVvDr5tc3rKnBXVT71hOay7RSx1StWzXiJs9mjaeVMJzYzRT78= diff --git a/api/mount.js b/api/mount.js index 5f821b7c..0465c4c7 100644 --- a/api/mount.js +++ b/api/mount.js @@ -1,14 +1,25 @@ "use strict" -var coreRenderer = require("../render/render") +var Vnode = require("../render/vnode") var autoredraw = require("../api/autoredraw") +var dummy = {view: function() {}} module.exports = function(renderer, pubsub) { return function(root, component) { + pubsub.unsubscribe(root.redraw) + var run = autoredraw(root, renderer, pubsub, function() { - renderer.render(root, {tag: component}) + renderer.render( + root, + Vnode(component === null ? dummy : component, undefined, undefined, undefined, undefined, undefined) + ) }) run() + + if (component === null) { + pubsub.unsubscribe(root.redraw) + delete root.redraw + } } } diff --git a/api/tests/test-mount.js b/api/tests/test-mount.js index 6baeea67..ee383c65 100644 --- a/api/tests/test-mount.js +++ b/api/tests/test-mount.js @@ -32,6 +32,20 @@ o.spec("mount", function() { o(root.firstChild.nodeName).equals("DIV") }) + o("mounting null deletes `redraw` from `root`", function() { + mount(root, { + view : function() { + return m("div") + } + }) + + o(typeof root.redraw).equals('function') + + mount(root, null) + + o(typeof root.redraw).equals('undefined') + }) + o("redraws on events", function(done) { var onupdate = o.spy() var oninit = o.spy() diff --git a/docs/mount.md b/docs/mount.md index 95540b06..a402e1e6 100644 --- a/docs/mount.md +++ b/docs/mount.md @@ -2,6 +2,7 @@ - [API](#api) - [How it works](#how-it-works) +- [Performance considerations](#performance-considerations) - [Differences from m.render](#differences-from-m-render) --- @@ -13,7 +14,7 @@ Argument | Type | Required | Description ----------- | -------------------- | -------- | --- `element` | `Element` | Yes | A DOM element that will be the parent node to the subtree -`component` | `Component` | Yes | The [component](components.md) to be rendered +`component` | `Component|null` | Yes | The [component](components.md) to be rendered. `null` unmounts the tree and cleans up internal state. **returns** | | | Returns nothing [How to read signatures](signatures.md) @@ -24,6 +25,18 @@ Argument | Type | Required | Description Similar to [`m.render()`](render.md), the `m.mount()` method takes a component and mounts a corresponding DOM tree into `element`. If `element` already has a DOM tree mounted via a previous `m.mount()` call, the component is diffed against the previous vnode tree and the existing DOM tree is modified only where needed to reflect the changes. Unchanged DOM nodes are not touched at all. +#### Replace a component + +Running `mount(element, OtherComponent)` where `element` is a current mount point replaces the component previously mounted with `OtherComponent`. + +#### Unmount + +Using `m.mount(element, null)` on an element with a previously mounted component unmounts it and cleans up Mithril internal state. This can be useful to prevent memory leaks when removing the `root` node manually from the DOM. + +--- + +### Performance considerations + It may seem wasteful to generate a vnode tree on every redraw, but as it turns out, creating and comparing Javascript data structures is surprisingly cheap compared to reading and modifying the DOM. Touching the DOM can be extremely expensive for a couple of reasons. Alternating reads and writes can adversely affect performance by causing several browser repaints to occur in quick succession, whereas comparing virtual dom trees allows writes to be batched into a single repaint. Also, the performance characteristics of various DOM operations vary between implementations and can be difficult to learn and optimize for all browsers. For example, in some implementations, reading `childNodes.length` has a complexity of O(n); in some, reading `parentNode` causes a repaint, etc. diff --git a/docs/render.md b/docs/render.md index 14d896d4..a1b09c3a 100644 --- a/docs/render.md +++ b/docs/render.md @@ -35,7 +35,7 @@ In contrast, traversing a javascript data structure has a much more predictable ### Differences from other API methods -`m.render()` method is internally called by [`m.mount()`](mount.md), [`m.route()`](route.md), [`m.redraw()`](redraw.md) and `[m.request()](request.md)`. It is not called by [`m.prop()`](prop.md) +`m.render()` method is internally called by [`m.mount()`](mount.md), [`m.route()`](route.md), [`m.redraw()`](redraw.md) and [`m.request()`](request.md). It is not called by [`m.prop()`](prop.md) Unlike with `m.mount()` and `m.route()`, a vnode tree rendered via `m.render()` does not auto-redraw in response to view events, `m.redraw()` calls or `m.request()` calls. It is a low level mechanism suitable for library authors who wish to manually control rendering instead of relying on Mithril's built-in auto-redrawing system. diff --git a/docs/request.md b/docs/request.md index 4fd9a1cf..6ab13da3 100644 --- a/docs/request.md +++ b/docs/request.md @@ -32,7 +32,7 @@ Argument | Type | Required | Descript `options.type` | `any = Function(any)` | No | A constructor to be applied to each object in the response. Defaults to the [identity function](https://en.wikipedia.org/wiki/Identity_function). `options.serialize` | `string = Function(any)` | No | A serialization method to be applied to `data`. Defaults to `JSON.stringify`, or if `options.data` is an instance of [`FormData`](https://developer.mozilla.org/en/docs/Web/API/FormData), defaults to the [identity function](https://en.wikipedia.org/wiki/Identity_function) (i.e. `function(value) {return value}`). `options.deserialize` | `any = Function(string)` | No | A deserialization method to be applied to the response. Defaults to a small wrapper around `JSON.parse` that returns `null` for empty responses. -`options.extract` | `string = Function(xhr, options)` | No | A hook to specify how the XMLHttpRequest response should be read. Useful for reading response headers and cookies. Defaults to a function that returns `xhr.responseText` +`options.extract` | `string = Function(xhr, options)` | No | A hook to specify how the XMLHttpRequest response should be read. Useful for reading response headers and cookies. Defaults to a function that returns `xhr.responseText`. If defined, `options.deserialize` is ignored. `options.initialValue` | `any` | No | A value to populate the returned stream before the request completes `options.useBody` | `Boolean` | No | Force the use of the HTTP body section for `data` in `GET` requests when set to `true`, or the use of querystring for other HTTP methods when set to `false`. Defaults to `false` for `GET` requests and `true` for other methods. **returns** | `Stream` | | A stream that resolves to the response data, after it has been piped through the `extract`, `deserialize` and `type` methods @@ -378,6 +378,26 @@ Ignoring the fact that the parseCSV function above doesn't handle a lot of cases --- + +### Retrieving response details + +By default Mithril attempts to parse a response as JSON and returns `xhr.responseText`. It may be useful to inspect a server response in more detail, this can be accomplished by passing a custom `options.extract` function: + +```javascript +m.request({ + method: "GET", + url: "/api/v1/users", + extract: function(xhr) {return {status: xhr.status, body: xhr.responseText}} +}) +.run(function(response) { + console.log(response.status, response.body) +}) +``` + +The parameter to `options.extract` is the XMLHttpRequest object once its operation is completed, but before it has been passed to the resulting [stream](prop.md), so the stream may still end up in an errored state if processing throws an exception. + +--- + ### Why JSON instead of HTML Many server-side frameworks provide a view engine that interpolates database data into a template before serving HTML (on page load or via AJAX) and then employ jQuery to handle user interactions. diff --git a/docs/v1.x-migration.md b/docs/v1.x-migration.md index 20cfbeb6..11a6b2c9 100644 --- a/docs/v1.x-migration.md +++ b/docs/v1.x-migration.md @@ -7,6 +7,7 @@ - [Component `controller` function](#component-controller-function) - [Component arguments](#component-arguments) - [Passing components to `m()`](#passing-components-to-m) +- [Passing vnodes to `m.mount()` and `m.route()`](#passing-vnodes-to-mmount-and-mroute) - [`m.route` mode](#mroute-mode) - [`m.route` and anchor tags](#mroute-and-anchor-tags) - [Reading/writing the current route](#readingwriting-the-current-route) @@ -178,6 +179,34 @@ m("div", component); m("div", m(component)); ``` +## Passing vnodes to `m.mount()` and `m.route()` + +In `v0.2.x`, `m.mount(element, component)` tolerated [vnodes](vnodes.md) as second arguments instead of [components](components.md) (even though it wasn't documented). Likewise, `m.route(element, defaultRoute, routes)` accepted vnodes as values in the `routes` object. + +In `v1.x`, components are required instead in both cases. + +### `v0.2.x` + +```javascript +m.mount(element, m('i', 'hello')); +m.mount(element, m(Component, attrs)); + +m.route(element, '/', { + '/': m('b', 'bye') +}) +``` + +### `v1.x` + +```javascript +m.mount(element, {view: function () {return m('i', 'hello')}}); +m.mount(element, {view: function () {return m(Component, attrs)}}); + +m.route(element, '/', { + '/': {view: function () {return m('b', 'bye')}} +}) +``` + ## `m.route` mode `m.route.mode` was replaced by `m.route.prefix(prefix)` where `prefix` can be `#`, `?`, `` (for "pathname" mode). The new API also supports hashbang (`#!`), which is the default, and it supports non-root pathnames and arbitrary mode variations such as querybang (`?!`) @@ -330,3 +359,5 @@ m.prop.merge([ console.log("Contributors:", users[0].name, "and", users[1].name); }); ``` + +Additionally, if the `extract` option is passed to `m.request` the return value of the provided function will be passed to the [m.prop stream](prop.md) directly, and any `deserialize` callback is ignored. diff --git a/mithril.js b/mithril.js index 869da60a..9c83ef6b 100644 --- a/mithril.js +++ b/mithril.js @@ -348,7 +348,7 @@ var renderService = function($window) { } //update function updateNodes(parent, old, vnodes, hooks, nextSibling, ns) { - if (old == null && vnodes == null) return + if (old === vnodes || old == null && vnodes == null) return else if (old == null) createNodes(parent, vnodes, 0, vnodes.length, hooks, nextSibling, undefined) else if (vnodes == null) removeNodes(parent, old, 0, old.length, vnodes) else { @@ -357,7 +357,7 @@ var renderService = function($window) { if (old.length === vnodes.length && vnodes[0] != null && vnodes[0].key == null) { for (var i = 0; i < old.length; i++) { - if (old[i] == null && vnodes[i] == null) continue + if (old[i] === vnodes[i] || old[i] == null && vnodes[i] == null) continue else if (old[i] == null) insertNode(parent, createNode(vnodes[i], hooks, ns), getNextSibling(old, i + 1, nextSibling)) else if (vnodes[i] == null) removeNodes(parent, old, i, i + 1, vnodes) else updateNode(parent, old[i], vnodes[i], hooks, getNextSibling(old, i + 1, nextSibling), recycling, ns) @@ -675,11 +675,11 @@ var renderService = function($window) { } //style function updateStyle(element, old, style) { - if (old === style) element.style = "", old = null - if (style == null) element.style = "" - else if (typeof style === "string") element.style = style + if (old === style) element.cssText = "", old = null + if (style == null) element.cssText = "" + else if (typeof style === "string") element.cssText = style else { - if (typeof old === "string") element.style = "" + if (typeof old === "string") element.cssText = "" for (var key in style) { element.style[key] = style[key] } @@ -744,7 +744,12 @@ var renderService = function($window) { function render(dom, vnodes) { var hooks = [] var active = $doc.activeElement - if (dom.vnodes == null) dom.vnodes = [] + + // First time rendering into a node clears it out + if (dom.vnodes == null) { + dom.vnodes = [] + dom.textContent = ""; + } if (!(vnodes instanceof Array)) vnodes = [vnodes] updateNodes(dom, dom.vnodes, Vnode.normalizeChildren(vnodes), hooks, null, undefined) dom.vnodes = vnodes @@ -808,7 +813,7 @@ var requestService = function($window) { xhr.onreadystatechange = function() { if (xhr.readyState === 4) { try { - var response = args.deserialize(args.extract(xhr, args)) + var response = (args.extract !== extract) ? args.extract(xhr, args) : args.deserialize(args.extract(xhr, args)) if (xhr.status >= 200 && xhr.status < 300) { stream(cast(args.type, response)) } @@ -1103,12 +1108,21 @@ m.route = function($window, renderer, pubsub) { return route }(window, renderService, redrawService) +var dummy = {view: function() {}} m.mount = function(renderer, pubsub) { return function(root, component) { + pubsub.unsubscribe(root.redraw) var run = autoredraw(root, renderer, pubsub, function() { - renderer.render(root, {tag: component}) + renderer.render( + root, + Vnode(component === null ? dummy : component, undefined, undefined, undefined, undefined, undefined) + ) }) run() + if (component === null) { + pubsub.unsubscribe(root.redraw) + delete root.redraw + } } }(renderService, redrawService) m.trust = function(html) { diff --git a/mithril.min.js b/mithril.min.js index 3b2074e8..56430b46 100644 --- a/mithril.min.js +++ b/mithril.min.js @@ -1,40 +1,40 @@ -new function(){(function(){function y(){function a(){0=q&&z>=A;){var t=c[q],w=f[A];if(t===w)q++,A++;else if(null!=t&&null!=w&&t.key===w.key)q++,A++,k(a,t,w,d,e(c,q,g),p,B),p&&t.tag===w.tag&&n(a,m(t),g);else if(t=c[r],t===w)r--,A++;else if(null!=t&&null!=w&&t.key===w.key)k(a,t,w,d,e(c,r+1,g),p,B),n(a,m(t),e(c,q,g)),r--,A++;else break}for(;r>=q&&z>=A;){t=c[r];w=f[z]; -if(t===w)r--;else if(null!=t&&null!=w&&t.key===w.key)k(a,t,w,d,e(c,r+1,g),p,B),p&&t.tag===w.tag&&n(a,m(t),g),null!=t.dom&&(g=t.dom),r--;else{if(!u){u=c;var t=r,l={},x;for(x=0;xa.indexOf("?")?"?":"&";a+=g+d}return a}function d(a){try{return""!==a?JSON.parse(a):null}catch(b){throw Error(a);}}function g(a){return a.responseText}var k=0,m;return{xhr:function(e){var n=C.stream();void 0!==e.initialValue&&n(e.initialValue);var v="boolean"===typeof e.useBody?e.useBody:"GET"!==e.method&& -"TRACE"!==e.method;"function"!==typeof e.serialize&&(e.serialize="undefined"!==typeof FormData&&e.data instanceof FormData?function(a){return a}:JSON.stringify);"function"!==typeof e.deserialize&&(e.deserialize=d);"function"!==typeof e.extract&&(e.extract=g);e.url=b(e.url,e.data);v?e.data=e.serialize(e.data):e.url=h(e.url,e.data);var k=new a.XMLHttpRequest;k.open(e.method,e.url,"boolean"===typeof e.async?e.async:!0,"string"===typeof e.user?e.user:void 0,"string"===typeof e.password?e.password:void 0); -e.serialize===JSON.stringify&&v&&k.setRequestHeader("Content-Type","application/json; charset=utf-8");e.deserialize===d&&k.setRequestHeader("Accept","application/json, text/*");"function"===typeof e.config&&(k=e.config(k,e)||k);k.onreadystatechange=function(){if(4===k.readyState){try{var a=e.deserialize(e.extract(k,e));if(200<=k.status&&300>k.status){if("function"===typeof e.type)if(a instanceof Array)for(var b=0;b=r&&B>=A;){var t=c[r],w=e[A];if(t===w)r++,A++;else if(null!=t&&null!=w&&t.key===w.key)r++,A++,p(a,t,w,d,m(c,r,g),z,k),z&&t.tag===w.tag&&f(a,l(t),g);else if(t=c[v],t===w)v--,A++;else if(null!=t&&null!=w&&t.key===w.key)p(a,t,w,d,m(c,v+1,g),z,k),f(a,l(t),m(c,r,g)),v--,A++;else break}for(;v>= +r&&B>=A;){t=c[v];w=e[B];if(t===w)v--;else if(null!=t&&null!=w&&t.key===w.key)p(a,t,w,d,m(c,v+1,g),z,k),z&&t.tag===w.tag&&f(a,l(t),g),null!=t.dom&&(g=t.dom),v--;else{if(!u){u=c;var t=v,n={},x;for(x=0;xa.indexOf("?")?"?":"&";a+=g+d}return a}function d(a){try{return""!==a?JSON.parse(a):null}catch(b){throw Error(a);}}function g(a){return a.responseText}function p(a,b){if("function"===typeof a)if(b instanceof Array)for(var d=0;dk.status)q(p(f.type,a));else{var b=Error(k.responseText),d;for(d in a)b[d]=a[d];q.error(b)}}catch(h){q.error(h)}"function"===typeof m&&m()}};l?k.send(f.data):k.send();return q},jsonp:function(f){var d=C.stream();void 0!==f.initialValue&&d(f.initialValue);var g=f.callbackName||"_mithril_"+Math.round(1E16*Math.random())+"_"+l++,k=a.document.createElement("script");a[g]=function(b){k.parentNode.removeChild(k);d(p(f.type,b));"function"===typeof m&&m();delete a[g]};k.onerror= +function(){k.parentNode.removeChild(k);d.error(Error("JSONP request failed"));"function"===typeof m&&m();delete a[g]};null==f.data&&(f.data={});f.url=b(f.url,f.data);f.data[f.callbackKey||"callback"]=g;k.src=h(f.url,f.data);a.document.documentElement.appendChild(k);return d},setCompletionCallback:function(a){m=a}}}(window),H=function(){var a=[];return{subscribe:a.push.bind(a),unsubscribe:function(b){b=a.indexOf(b);-1 + diff --git a/render/tests/test-render.js b/render/tests/test-render.js new file mode 100644 index 00000000..f23c9565 --- /dev/null +++ b/render/tests/test-render.js @@ -0,0 +1,24 @@ +"use strict" + +var o = require("../../ospec/ospec") +var domMock = require("../../test-utils/domMock") +var vdom = require("../../render/render") + +o.spec("render", function() { + var $window, root, render + o.beforeEach(function() { + $window = domMock() + root = $window.document.createElement("div") + render = vdom($window).render + }) + + o("overwrites existing content", function() { + var vnodes = [] + + root.appendChild($window.document.createElement("div")); + + render(root, vnodes) + + o(root.childNodes.length).equals(0) + }) +}) diff --git a/render/tests/test-updateNodes.js b/render/tests/test-updateNodes.js index a3c39394..b8468bc8 100644 --- a/render/tests/test-updateNodes.js +++ b/render/tests/test-updateNodes.js @@ -845,4 +845,22 @@ o.spec("updateNodes", function() { o(root.childNodes[0].nodeName).equals("A") o(root.childNodes[1].nodeName).equals("S") }) + o("cached, non-keyed nodes skip diff", function () { + var onupdate = o.spy(); + var cached = {tag:"a", attrs:{onupdate: onupdate}} + + render(root, cached) + render(root, cached) + + o(onupdate.callCount).equals(0) + }) + o("cached, keyed nodes skip diff", function () { + var onupdate = o.spy(); + var cached = {tag:"a", key:"a", attrs:{onupdate: onupdate}} + + render(root, cached) + render(root, cached) + + o(onupdate.callCount).equals(0) + }) }) diff --git a/request/request.js b/request/request.js index 01102d3b..7447bb7e 100644 --- a/request/request.js +++ b/request/request.js @@ -38,7 +38,7 @@ module.exports = function($window) { xhr.onreadystatechange = function() { if (xhr.readyState === 4) { try { - var response = args.deserialize(args.extract(xhr, args)) + var response = (args.extract !== extract) ? args.extract(xhr, args) : args.deserialize(args.extract(xhr, args)) if (xhr.status >= 200 && xhr.status < 300) { stream(cast(args.type, response)) } diff --git a/request/tests/test-xhr.js b/request/tests/test-xhr.js index bfbd1dca..3dc98902 100644 --- a/request/tests/test-xhr.js +++ b/request/tests/test-xhr.js @@ -210,7 +210,7 @@ o.spec("xhr", function() { } }) xhr({method: "GET", url: "/item", extract: extract}).map(function(data) { - o(data).deepEquals({test: 123}) + o(data).equals("{\"test\":123}") }).map(done) }) o("extract parameter works in POST", function(done) { @@ -224,7 +224,24 @@ o.spec("xhr", function() { } }) xhr({method: "POST", url: "/item", extract: extract}).map(function(data) { - o(data).deepEquals({test: 123}) + o(data).equals("{\"test\":123}") + }).map(done) + }) + o("ignores deserialize if extract is defined", function(done) { + var extract = function(data) { + return data.status + } + var deserialize = o.spy() + + mock.$defineRoutes({ + "GET /item": function(request) { + return {status: 200, responseText: ""} + } + }) + xhr({method: "GET", url: "/item", extract: extract, deserialize: deserialize}).map(function(data) { + o(data).equals(200) + }).map(function() { + o(deserialize.callCount).equals(0) }).map(done) }) o("config parameter works", function(done) { diff --git a/test-utils/domMock.js b/test-utils/domMock.js index 0af6b252..cde58a24 100644 --- a/test-utils/domMock.js +++ b/test-utils/domMock.js @@ -136,7 +136,11 @@ module.exports = function() { get style() { return style }, - set style(value) { + set style(_){ + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style#Setting_style + throw new Error("setting element.style is not portable") + }, + set cssText(value) { if (typeof value === "string") { for (var key in style) style[key] = "" var rules = value.split(";") diff --git a/test-utils/tests/test-domMock.js b/test-utils/tests/test-domMock.js index 527b4e61..2b589673 100644 --- a/test-utils/tests/test-domMock.js +++ b/test-utils/tests/test-domMock.js @@ -474,20 +474,30 @@ o.spec("domMock", function() { o(typeof div.style).equals("object") }) - o("setting style string works", function() { + o("setting cssText string works", function() { var div = $document.createElement("div") - div.style = "background-color: red; border-bottom: 1px solid red;" + div.cssText = "background-color: red; border-bottom: 1px solid red;" o(div.style.backgroundColor).equals("red") o(div.style.borderBottom).equals("1px solid red") }) - o("removing via setting style string works", function() { + o("removing via setting cssText string works", function() { var div = $document.createElement("div") - div.style = "background: red;" - div.style = "" + div.cssText = "background: red;" + div.cssText = "" o(div.style.background).equals("") }) + o("setting style throws", function () { + var err = false + try { + div.style = '' + } catch (e) { + err = e + } + + o(err instanceof Error).equals(true) + }) }) o.spec("events", function() { o.spec("click", function() {