From 605fa447111711b28aba24570d34f7f5a0230b05 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Gerardy Date: Thu, 8 Dec 2016 15:18:37 +0100 Subject: [PATCH 01/15] [api/router] Test for two deferred onmatch racing --- api/tests/test-router.js | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/api/tests/test-router.js b/api/tests/test-router.js index 7ea00195..7424e929 100644 --- a/api/tests/test-router.js +++ b/api/tests/test-router.js @@ -932,6 +932,63 @@ o.spec("route", function() { }) }) + o.only("when two async routes are racing, the last one set cancels the finalization of the first", function(done) { + var renderA = o.spy() + var renderB = o.spy() + var onmatchA = o.spy(function(){ + return new Promise(function(fulfill) { + setTimeout(function(){ + fulfill() + }, 10) + }) + }) + + $window.location.href = prefix + "/a" + route(root, "/a", { + "/a": { + onmatch: onmatchA, + render: renderA + }, + "/b": { + onmatch: function(){ + var p = new Promise(function(fulfill) { + o(onmatchA.callCount).equals(1) + o(renderA.callCount).equals(0) + o(renderB.callCount).equals(0) + + setTimeout(function(){ + o(onmatchA.callCount).equals(1) + o(renderA.callCount).equals(0) + o(renderB.callCount).equals(0) + + fulfill() + + p.then(function(){ + o(onmatchA.callCount).equals(1) + o(renderA.callCount).equals(0) + o(renderB.callCount).equals(1) + + done() + }) + }, 20) + }) + return p + }, + render: renderB + } + }) + + callAsync(function() { + o(onmatchA.callCount).equals(1) + o(renderA.callCount).equals(0) + o(renderB.callCount).equals(0) + route.set("/b") + o(onmatchA.callCount).equals(1) + o(renderA.callCount).equals(0) + o(renderB.callCount).equals(0) + }) + }) + o("m.route.set(m.route.get()) re-runs the resolution logic (#1180)", function(done){ var onmatch = o.spy() var render = o.spy(function() {return m("div")}) From 703aab7905c6c066b427307dbad3f27247987d07 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Gerardy Date: Thu, 8 Dec 2016 21:44:21 +0100 Subject: [PATCH 02/15] [router] Fix hopefully the last race condition --- api/router.js | 24 ++++++++++++------------ api/tests/test-router.js | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/api/router.js b/api/router.js index 1e1650d5..ccfccb94 100644 --- a/api/router.js +++ b/api/router.js @@ -8,14 +8,9 @@ module.exports = function($window, redrawService) { var routeService = coreRouter($window) var identity = function(v) {return v} - var render, component, attrs, currentPath, updatePending = false + var render, component, attrs, currentPath, lastUpdate var route = function(root, defaultRoute, routes) { if (root == null) throw new Error("Ensure the DOM element that was passed to `m.route` is not undefined") - var update = function(routeResolver, comp, params, path) { - component = comp != null && typeof comp.view === "function" ? comp : "div", attrs = params, currentPath = path, updatePending = false - render = (routeResolver.render || identity).bind(routeResolver) - run() - } var run = function() { if (render != null) redrawService.render(root, render(Vnode(component, attrs.key, attrs))) } @@ -23,22 +18,27 @@ module.exports = function($window, redrawService) { routeService.setPath(defaultRoute) } routeService.defineRoutes(routes, function(payload, params, path) { - if (payload.view) update({}, payload, params, path) + var update = lastUpdate = function(routeResolver, comp) { + if (update !== lastUpdate) return + component = comp != null && typeof comp.view === "function" ? comp : "div", attrs = params, currentPath = path, lastUpdate = null + render = (routeResolver.render || identity).bind(routeResolver) + run() + } + if (payload.view) update({}, payload) else { if (payload.onmatch) { - updatePending = true Promise.resolve(payload.onmatch(params, path)).then(function(resolved) { - if (updatePending) update(payload, resolved, params, path) + update(payload, resolved) }, bail) } - else update(payload, "div", params, path) + else update(payload, "div") } }, bail) redrawService.subscribe(root, run) } route.set = function(path, data, options) { - if (updatePending) options = {replace: true} - updatePending = false + if (lastUpdate != null) options = {replace: true} + lastUpdate = null routeService.setPath(path, data, options) } route.get = function() {return currentPath} diff --git a/api/tests/test-router.js b/api/tests/test-router.js index 7424e929..7e2454cb 100644 --- a/api/tests/test-router.js +++ b/api/tests/test-router.js @@ -932,7 +932,7 @@ o.spec("route", function() { }) }) - o.only("when two async routes are racing, the last one set cancels the finalization of the first", function(done) { + o("when two async routes are racing, the last one set cancels the finalization of the first", function(done) { var renderA = o.spy() var renderB = o.spy() var onmatchA = o.spy(function(){ From 0170cf0c88f9aa8e17febf8692322fc1bc2e35e9 Mon Sep 17 00:00:00 2001 From: Pat Cavit Date: Thu, 15 Dec 2016 07:04:33 +0000 Subject: [PATCH 03/15] Fix anchor --- docs/mount.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/mount.md b/docs/mount.md index a188668b..f8a28e95 100644 --- a/docs/mount.md +++ b/docs/mount.md @@ -4,7 +4,7 @@ - [Signature](#signature) - [How it works](#how-it-works) - [Performance considerations](#performance-considerations) -- [Differences from m.render](#differences-from-m-render) +- [Differences from m.render](#differences-from-mrender) --- @@ -73,4 +73,4 @@ A component rendered via `m.mount` automatically auto-redraws in response to vie `m.mount()` is suitable for application developers integrating Mithril widgets into existing codebases where routing is handled by another library or framework, while still enjoying Mithril's auto-redrawing facilities. -`m.render()` is suitable for library authors who wish to manually control rendering (e.g. when hooking to a third party router, or using third party data-layer libraries like Redux). \ No newline at end of file +`m.render()` is suitable for library authors who wish to manually control rendering (e.g. when hooking to a third party router, or using third party data-layer libraries like Redux). From 45c6b473b8be5993d8c1f9b5b754261d44f04e8d Mon Sep 17 00:00:00 2001 From: Barney Carroll Date: Fri, 16 Dec 2016 10:34:12 +0000 Subject: [PATCH 04/15] Revert #1449 --- .npmignore | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/.npmignore b/.npmignore index 48873c87..bf023d92 100644 --- a/.npmignore +++ b/.npmignore @@ -1,20 +1,8 @@ -# Configuration files +# Development-specific files .deploy.env .editorconfig .eslintrc.js .gitattributes .gitignore .travis.yml - -# Tests -test-utils/ -tests/ - -# Documentation -docs/ -examples/ CONTRIBUTING.md - -# Browser stub (use index.js w/ a bundler or mithril.js w/o one instead) -module/ -browser.js From c95629c62352c453920a71a9c5bcca1e9a186ed8 Mon Sep 17 00:00:00 2001 From: Leo Horie Date: Fri, 16 Dec 2016 15:21:57 -0500 Subject: [PATCH 05/15] handles noop from undefined to null #1473 --- render/render.js | 2 +- render/tests/test-updateNodes.js | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/render/render.js b/render/render.js index 9ee2628e..023491f3 100644 --- a/render/render.js +++ b/render/render.js @@ -134,7 +134,7 @@ module.exports = function($window) { if (old.length === vnodes.length && isUnkeyed) { for (var i = 0; i < old.length; i++) { if (old[i] === vnodes[i]) continue - else if (old[i] == null) insertNode(parent, createNode(vnodes[i], hooks, ns), getNextSibling(old, i + 1, nextSibling)) + else if (old[i] == null && vnodes[i] != null) insertNode(parent, createNode(vnodes[i], hooks, ns), getNextSibling(old, i + 1, nextSibling)) else if (vnodes[i] == null) removeNodes(old, i, i + 1, vnodes) else updateNode(parent, old[i], vnodes[i], hooks, getNextSibling(old, i + 1, nextSibling), false, ns) } diff --git a/render/tests/test-updateNodes.js b/render/tests/test-updateNodes.js index 7b0f9715..524db029 100644 --- a/render/tests/test-updateNodes.js +++ b/render/tests/test-updateNodes.js @@ -104,6 +104,15 @@ o.spec("updateNodes", function() { o(updated[0].dom.nodeValue).equals("a") o(updated[0].dom).equals(root.childNodes[0]) }) + o("handles undefined to null noop", function() { + var vnodes = [null, {tag: "div"}] + var updated = [undefined, {tag: "div"}] + + render(root, vnodes) + render(root, updated) + + o(root.childNodes.length).equals(1) + }) o("reverses els w/ even count", function() { var vnodes = [{tag: "a", key: 1}, {tag: "b", key: 2}, {tag: "i", key: 3}, {tag: "s", key: 4}] var updated = [{tag: "s", key: 4}, {tag: "i", key: 3}, {tag: "b", key: 2}, {tag: "a", key: 1}] From 2b90b37e72270d306a4e7778f5c403214c1f83f0 Mon Sep 17 00:00:00 2001 From: Gandalf-the-Bot Date: Fri, 16 Dec 2016 20:23:33 +0000 Subject: [PATCH 06/15] Bundled output for commit 297146a2cc041d1b41b8255ba35efc1358ec80eb [skip ci] --- mithril.js | 2 +- mithril.min.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mithril.js b/mithril.js index e5558df4..7ca741d0 100644 --- a/mithril.js +++ b/mithril.js @@ -463,7 +463,7 @@ var coreRenderer = function($window) { if (old.length === vnodes.length && isUnkeyed) { for (var i = 0; i < old.length; i++) { if (old[i] === vnodes[i]) continue - else if (old[i] == null) insertNode(parent, createNode(vnodes[i], hooks, ns), getNextSibling(old, i + 1, nextSibling)) + else if (old[i] == null && vnodes[i] != null) insertNode(parent, createNode(vnodes[i], hooks, ns), getNextSibling(old, i + 1, nextSibling)) else if (vnodes[i] == null) removeNodes(old, i, i + 1, vnodes) else updateNode(parent, old[i], vnodes[i], hooks, getNextSibling(old, i + 1, nextSibling), false, ns) } diff --git a/mithril.min.js b/mithril.min.js index 69e76332..d4facde4 100644 --- a/mithril.min.js +++ b/mithril.min.js @@ -16,7 +16,7 @@ H),O=function(a){function c(e,f,a,b,c,d,g){for(;a=k&&t>=A;){var x=f[k],u=a[A];if(x!==u||q)if(null==x)k++;else if(null==u)A++;else if(x.key===u.key)k++,A++,m(e,x,u,b,n(f,k,d),q,g),q&&x.tag===u.tag&&p(e,l(x),d);else if(x=f[y],x!==u||q)if(null==x)y--;else if(null==u)A++;else if(x.key===u.key)m(e,x,u,b,n(f,y+1,d),q,g),(q||A=k&&t>=A;){x=f[y];u=a[t];if(x!==u||q)if(null==x)y--;else{if(null!= u)if(x.key===u.key)m(e,x,u,b,n(f,y+1,d),q,g),q&&x.tag===u.tag&&p(e,l(x),d),null!=x.dom&&(d=x.dom),y--;else{if(!D){D=f;var x=y,C={},w;for(w=0;w Date: Fri, 16 Dec 2016 15:28:07 -0500 Subject: [PATCH 07/15] make note in docs about m.route singletonness --- docs/route.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/route.md b/docs/route.md index 2d4112cc..f941bce7 100644 --- a/docs/route.md +++ b/docs/route.md @@ -39,6 +39,8 @@ m.route(document.body, "/home", { }) ``` +You can only have one `m.route` call per application. + --- ### Signature From 0c3c6a5af968f635a94f8eef04eed983f0924ab9 Mon Sep 17 00:00:00 2001 From: Gandalf-the-Bot Date: Sat, 17 Dec 2016 22:57:46 +0000 Subject: [PATCH 08/15] Bundled output for commit fe8a37dcb7fae45732ffff4c26fc87b8f5bda62d [skip ci] --- mithril.js | 24 +++++++++---------- mithril.min.js | 64 +++++++++++++++++++++++++------------------------- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/mithril.js b/mithril.js index 7ca741d0..2e3af836 100644 --- a/mithril.js +++ b/mithril.js @@ -1081,14 +1081,9 @@ var coreRouter = function($window) { var _20 = function($window, redrawService0) { var routeService = coreRouter($window) var identity = function(v) {return v} - var render1, component, attrs3, currentPath, updatePending = false + var render1, component, attrs3, currentPath, lastUpdate var route = function(root, defaultRoute, routes) { if (root == null) throw new Error("Ensure the DOM element that was passed to `m.route` is not undefined") - var update = function(routeResolver, comp, params, path) { - component = comp != null && typeof comp.view === "function" ? comp : "div", attrs3 = params, currentPath = path, updatePending = false - render1 = (routeResolver.render || identity).bind(routeResolver) - run1() - } var run1 = function() { if (render1 != null) redrawService0.render(root, render1(Vnode(component, attrs3.key, attrs3))) } @@ -1096,22 +1091,27 @@ var _20 = function($window, redrawService0) { routeService.setPath(defaultRoute) } routeService.defineRoutes(routes, function(payload, params, path) { - if (payload.view) update({}, payload, params, path) + var update = lastUpdate = function(routeResolver, comp) { + if (update !== lastUpdate) return + component = comp != null && typeof comp.view === "function" ? comp : "div", attrs3 = params, currentPath = path, lastUpdate = null + render1 = (routeResolver.render || identity).bind(routeResolver) + run1() + } + if (payload.view) update({}, payload) else { if (payload.onmatch) { - updatePending = true Promise.resolve(payload.onmatch(params, path)).then(function(resolved) { - if (updatePending) update(payload, resolved, params, path) + update(payload, resolved) }, bail) } - else update(payload, "div", params, path) + else update(payload, "div") } }, bail) redrawService0.subscribe(root, run1) } route.set = function(path, data, options) { - if (updatePending) options = {replace: true} - updatePending = false + if (lastUpdate != null) options = {replace: true} + lastUpdate = null routeService.setPath(path, data, options) } route.get = function() {return currentPath} diff --git a/mithril.min.js b/mithril.min.js index d4facde4..0c599ac7 100644 --- a/mithril.min.js +++ b/mithril.min.js @@ -1,41 +1,41 @@ -new function(){function w(a,c,h,d,g,m){return{tag:a,key:c,attrs:h,children:d,text:g,dom:m,domSize:void 0,state:{},events:void 0,instance:void 0,skip:!1}}function z(a){if(null==a||"string"!==typeof a&&null==a.view)throw Error("The selector must be either a string or a component.");if("string"===typeof a&&void 0===K[a]){for(var c,h,d=[],g={};c=P.exec(a);){var m=c[1],l=c[2];""===m&&""!==l?h=l:"#"===m?g.id=l:"."===m?d.push(l):"["===c[3][0]&&((m=c[6])&&(m=m.replace(/\\(["'])/g,"$1").replace(/\\\\/g,"\\")), -"class"===c[4]?d.push(m):g[c[4]]=m||!0)}0a.indexOf("?")?"?":"&";a+=d+b}return a}function l(a){try{return""!==a?JSON.parse(a):null}catch(D){throw Error(a);}}function n(a){return a.responseText}function p(a, +new function(){function v(a,c,h,d,g,m){return{tag:a,key:c,attrs:h,children:d,text:g,dom:m,domSize:void 0,state:{},events:void 0,instance:void 0,skip:!1}}function z(a){if(null==a||"string"!==typeof a&&null==a.view)throw Error("The selector must be either a string or a component.");if("string"===typeof a&&void 0===J[a]){for(var c,h,d=[],g={};c=P.exec(a);){var m=c[1],l=c[2];""===m&&""!==l?h=l:"#"===m?g.id=l:"."===m?d.push(l):"["===c[3][0]&&((m=c[6])&&(m=m.replace(/\\(["'])/g,"$1").replace(/\\\\/g,"\\")), +"class"===c[4]?d.push(m):g[c[4]]=m||!0)}0a.indexOf("?")?"?":"&";a+=d+b}return a}function l(a){try{return""!==a?JSON.parse(a):null}catch(C){throw Error(a);}}function n(a){return a.responseText}function p(a, c){if("function"===typeof a)if(c instanceof Array)for(var b=0;bk.status||304===k.status)c(p(b.type,a));else{var g=Error(k.responseText),h;for(h in a)g[h]=a[h];d(g)}}catch(F){d(F)}};h&&null!=b.data?k.send(b.data):k.send()});return!0===b.background?t:r(t)},jsonp:function(b,l){var n=h();b=d(b,l);var t=new c(function(c, d){var h=b.callbackName||"_mithril_"+Math.round(1E16*Math.random())+"_"+k++,l=a.document.createElement("script");a[h]=function(d){l.parentNode.removeChild(l);c(p(b.type,d));delete a[h]};l.onerror=function(){l.parentNode.removeChild(l);d(Error("JSONP request failed"));delete a[h]};null==b.data&&(b.data={});b.url=g(b.url,b.data);b.data[b.callbackKey||"callback"]=h;l.src=m(b.url,b.data);a.document.documentElement.appendChild(l)});return!0===b.background?t:n(t)},setCompletionCallback:function(a){r=a}}}(window, -H),O=function(a){function c(e,f,a,b,c,d,g){for(;a=k&&t>=A;){var x=f[k],u=a[A];if(x!==u||q)if(null==x)k++;else if(null==u)A++;else if(x.key===u.key)k++,A++,m(e,x,u,b,n(f,k,d),q,g),q&&x.tag===u.tag&&p(e,l(x),d);else if(x=f[y],x!==u||q)if(null==x)y--;else if(null==u)A++;else if(x.key===u.key)m(e,x,u,b,n(f,y+1,d),q,g),(q||A=k&&t>=A;){x=f[y];u=a[t];if(x!==u||q)if(null==x)y--;else{if(null!= -u)if(x.key===u.key)m(e,x,u,b,n(f,y+1,d),q,g),q&&x.tag===u.tag&&p(e,l(x),d),null!=x.dom&&(d=x.dom),y--;else{if(!D){D=f;var x=y,C={},w;for(w=0;w=k&&t>=A;){var w=f[k],u=a[A];if(w!==u||q)if(null==w)k++;else if(null==u)A++;else if(w.key===u.key)k++,A++,m(e,w,u,b,n(f,k,d),q,g),q&&w.tag===u.tag&&p(e,l(w),d);else if(w=f[y],w!==u||q)if(null==w)y--;else if(null==u)A++;else if(w.key===u.key)m(e,w,u,b,n(f,y+1,d),q,g),(q||A=k&&t>=A;){w=f[y];u=a[t];if(w!==u||q)if(null==w)y--;else{if(null!= +u)if(w.key===u.key)m(e,w,u,b,n(f,y+1,d),q,g),q&&w.tag===u.tag&&p(e,l(w),d),null!=w.dom&&(d=w.dom),y--;else{if(!C){C=f;var w=y,v={},D;for(D=0;D Date: Sat, 17 Dec 2016 21:50:04 -0500 Subject: [PATCH 09/15] add support for route state #1480 --- README.md | 2 +- docs/route.md | 69 +++++++++++- mithril.js | 147 +++++++++++++------------ mithril.min.js | 82 +++++++------- render/render.js | 139 +++++++++++------------ render/tests/test-updateNodes.js | 12 +- router/router.js | 10 +- router/tests/test-setPath.js | 12 ++ test-utils/pushStateMock.js | 25 +++-- test-utils/tests/test-pushStateMock.js | 65 +++++++++++ 10 files changed, 367 insertions(+), 196 deletions(-) diff --git a/README.md b/README.md index 11a87ed7..ac65b7f1 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,6 @@ There are over 4000 assertions in the test suite, and tests cover even difficult ## Modularity -Despite the huge improvements in performance and modularity, the new codebase is smaller than v0.2.x, currently clocking at 7.47 KB min+gzip +Despite the huge improvements in performance and modularity, the new codebase is smaller than v0.2.x, currently clocking at 7.53 KB min+gzip In addition, Mithril is now completely modular: you can import only the modules that you need and easily integrate 3rd party modules if you wish to use a different library for routing, ajax, and even rendering diff --git a/docs/route.md b/docs/route.md index f941bce7..5ce6d1cd 100644 --- a/docs/route.md +++ b/docs/route.md @@ -14,6 +14,9 @@ - [Typical usage](#typical-usage) - [Navigating to different routes](#navigating-to-different-routes) - [Routing parameters](#routing-parameters) + - [Key parameter](#key-parameter) + - [Variadic routes](#variadic-routes) + - [History state](#history-state) - [Changing router prefix](#changing-router-prefix) - [Advanced component resolution](#advanced-component-resolution) - [Wrapping a layout component](#wrapping-a-layout-component) @@ -70,6 +73,8 @@ Argument | Type | Required | Description `path` | `String` | Yes | The path to route to, without a prefix. The path may include slots for routing parameters `data` | `Object` | No | Routing parameters. If `path` has routing parameter slots, the properties of this object are interpolated into the path string `options.replace` | `Boolean` | No | Whether to create a new history entry or to replace the current one. Defaults to false +`options.state` | `Object` | No | The `state` object to pass to the underlying `history.pushState` / `history.replaceState` call. This state object becomes available in the `history.state` property, and is merged into the [routing parameters](#routing-parameters) object. Note that this option only works when using the pushState API, but is ignored if the router falls back to hashchange mode (i.e. if the pushState API is not available) +`options.title` | `String` | No | The `title` string to pass to the underlying `history.pushState` / `history.replaceState` call. **returns** | | | Returns `undefined` ##### route.get @@ -254,6 +259,28 @@ It's possible to have multiple arguments in a route, for example `/edit/:project In addition to routing parameters, the `attrs` object also includes a `path` property that contains the current route path, and a `route` property that contains the matched routed. +#### Key parameter + +When a user navigates from a parameterized route to the same route with a different parameter (e.g. going from `/page/1` to `/page/2` given a route `/page/:id`, the component would not be recreated from scratch since both routes resolve to the same component, and thus result in a virtual dom in-place diff. This has the side-effect of triggering the `onupdate` hook, rather than `oninit`/`oncreate`. However, it's relatively common for a developer to want to synchronize the recreation of the component to the route change event. + +To achieve that, it's possible to combine route parameterization with the virtual dom [key reconciliation](keys.md) feature: + +```javascript +m.route(document.body, "/edit/1", { + "/edit/:key": Edit, +}) +``` + +This means that the [vnode](vnodes.md) that is created for the root component of the route has a route parameter object `key`. Route parameters become `attrs` in the vnode. Thus, when jumping from one page to another, the `key` changes and causes the component to be recreated from scratch (since the key tells the virtual dom engine that old and new components are different entities). + +You can take that idea further to create components that recreate themselves when reloaded: + +`m.route.set(m.route.get(), {key: Date.now()})` + +Or even use the [`history state`](#history-state) feature to achieve reloadable components without polluting the URL: + +`m.route.set(m.route.get(), null, {state: {key: Date.now()}})` + #### Variadic routes It's also possible to have variadic routes, i.e. a route with an argument that contains URL pathnames that contain slashes: @@ -264,6 +291,44 @@ m.route(document.body, "/edit/pictures/image.jpg", { }) ``` +#### History state + +It's possible to take full advantage of the underlying `history.pushState` API to improve user's navigation experience. For example, an application could "remember" the state of a large form when the user leaves a page by navigating away, such that if the user pressed the back button in the browser, they'd have the form filled rather than a blank form. + +For example, you could create a form like this: + +```javascript +var state = { + term: "", + search: function() { + // save the state for this route + // this is equivalent to `history.replaceState({term: state.term}, null, location.href)` + m.route.set(m.route.get(), null, {replace: true, state: {term: state.term}}) + + // navigate away + location.href = "https://google.com/?q=" + state.term + } +} + +var Form = { + oninit: function(vnode) { + state.term = vnode.attrs.term || "" // populated from the `history.state` property if the user presses the back button + }, + view: function() { + return m("form", [ + m("input[placeholder='Search']", {oninput: m.withAttr("value", function(v) {state.term = v}), value: state.term}), + m("button", {onclick: state.search}, "Search") + ]) + } +} + +m.route(document.body, "/", { + "/": Form, +}) +``` + +This way, if the user searches and presses the back button to return to the application, the input will still be populated with the search term. This technique can improve the user experience of large forms and other apps where non-persisted state is laborious for a user to produce. + --- ### Changing router prefix @@ -409,7 +474,7 @@ m.route(document.body, "/user/list", { "/user/list": { oninit: state.loadUsers, view: function() { - return state.users.length > 0 ? state.users.map(function() { + return state.users.length > 0 ? state.users.map(function(user) { return m("div", user.id) }) : "loading" } @@ -435,7 +500,7 @@ m.route(document.body, "/user/list", { "/user/list": { onmatch: state.loadUsers, render: function() { - return state.users.length > 0 ? state.users.map(function() { + return state.users.length > 0 ? state.users.map(function(user) { return m("div", user.id) }) : "loading" } diff --git a/mithril.js b/mithril.js index 7ca741d0..9ff9adfc 100644 --- a/mithril.js +++ b/mithril.js @@ -453,83 +453,84 @@ var coreRenderer = function($window) { else if (old == null) createNodes(parent, vnodes, 0, vnodes.length, hooks, nextSibling, undefined) else if (vnodes == null) removeNodes(old, 0, old.length, vnodes) else { - var isUnkeyed = false - for (var i = 0; i < vnodes.length; i++) { - if (vnodes[i] != null) { - isUnkeyed = vnodes[i].key == null - break + if (old.length === vnodes.length) { + var isUnkeyed = false + for (var i = 0; i < vnodes.length; i++) { + if (vnodes[i] != null && old[i] != null) { + isUnkeyed = vnodes[i].key == null && old[i].key == null + break + } + } + if (isUnkeyed) { + for (var i = 0; i < old.length; i++) { + if (old[i] === vnodes[i]) continue + else if (old[i] == null && vnodes[i] != null) insertNode(parent, createNode(vnodes[i], hooks, ns), getNextSibling(old, i + 1, nextSibling)) + else if (vnodes[i] == null) removeNodes(old, i, i + 1, vnodes) + else updateNode(parent, old[i], vnodes[i], hooks, getNextSibling(old, i + 1, nextSibling), false, ns) + } + return } } - if (old.length === vnodes.length && isUnkeyed) { - for (var i = 0; i < old.length; i++) { - if (old[i] === vnodes[i]) continue - else if (old[i] == null && vnodes[i] != null) insertNode(parent, createNode(vnodes[i], hooks, ns), getNextSibling(old, i + 1, nextSibling)) - else if (vnodes[i] == null) removeNodes(old, i, i + 1, vnodes) - else updateNode(parent, old[i], vnodes[i], hooks, getNextSibling(old, i + 1, nextSibling), false, ns) + var recycling = isRecyclable(old, vnodes) + if (recycling) old = old.concat(old.pool) + var oldStart = 0, start = 0, oldEnd = old.length - 1, end = vnodes.length - 1, map + while (oldEnd >= oldStart && end >= start) { + var o = old[oldStart], v = vnodes[start] + if (o === v && !recycling) oldStart++, start++ + else if (o == null) oldStart++ + else if (v == null) start++ + else if (o.key === v.key) { + oldStart++, start++ + updateNode(parent, o, v, hooks, getNextSibling(old, oldStart, nextSibling), recycling, ns) + if (recycling && o.tag === v.tag) insertNode(parent, toFragment(o), nextSibling) } - } - else { - var recycling = isRecyclable(old, vnodes) - if (recycling) old = old.concat(old.pool) - var oldStart = 0, start = 0, oldEnd = old.length - 1, end = vnodes.length - 1, map - while (oldEnd >= oldStart && end >= start) { - var o = old[oldStart], v = vnodes[start] - if (o === v && !recycling) oldStart++, start++ - else if (o == null) oldStart++ + else { + var o = old[oldEnd] + if (o === v && !recycling) oldEnd--, start++ + else if (o == null) oldEnd-- else if (v == null) start++ - else if (o.key === v.key) { - oldStart++, start++ - updateNode(parent, o, v, hooks, getNextSibling(old, oldStart, nextSibling), recycling, ns) - if (recycling && o.tag === v.tag) insertNode(parent, toFragment(o), nextSibling) - } - else { - var o = old[oldEnd] - if (o === v && !recycling) oldEnd--, start++ - else if (o == null) oldEnd-- - else if (v == null) start++ - else if (o.key === v.key) { - updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), recycling, ns) - if (recycling || start < end) insertNode(parent, toFragment(o), getNextSibling(old, oldStart, nextSibling)) - oldEnd--, start++ - } - else break - } - } - while (oldEnd >= oldStart && end >= start) { - var o = old[oldEnd], v = vnodes[end] - if (o === v && !recycling) oldEnd--, end-- - else if (o == null) oldEnd-- - else if (v == null) end-- else if (o.key === v.key) { updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), recycling, ns) - if (recycling && o.tag === v.tag) insertNode(parent, toFragment(o), nextSibling) - if (o.dom != null) nextSibling = o.dom - oldEnd--, end-- + if (recycling || start < end) insertNode(parent, toFragment(o), getNextSibling(old, oldStart, nextSibling)) + oldEnd--, start++ } - else { - if (!map) map = getKeyMap(old, oldEnd) - if (v != null) { - var oldIndex = map[v.key] - if (oldIndex != null) { - var movable = old[oldIndex] - updateNode(parent, movable, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), recycling, ns) - insertNode(parent, toFragment(movable), nextSibling) - old[oldIndex].skip = true - if (movable.dom != null) nextSibling = movable.dom - } - else { - var dom = createNode(v, hooks, undefined) - insertNode(parent, dom, nextSibling) - nextSibling = dom - } - } - end-- - } - if (end < start) break + else break } - createNodes(parent, vnodes, start, end + 1, hooks, nextSibling, ns) - removeNodes(old, oldStart, oldEnd + 1, vnodes) } + while (oldEnd >= oldStart && end >= start) { + var o = old[oldEnd], v = vnodes[end] + if (o === v && !recycling) oldEnd--, end-- + else if (o == null) oldEnd-- + else if (v == null) end-- + else if (o.key === v.key) { + updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), recycling, ns) + if (recycling && o.tag === v.tag) insertNode(parent, toFragment(o), nextSibling) + if (o.dom != null) nextSibling = o.dom + oldEnd--, end-- + } + else { + if (!map) map = getKeyMap(old, oldEnd) + if (v != null) { + var oldIndex = map[v.key] + if (oldIndex != null) { + var movable = old[oldIndex] + updateNode(parent, movable, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), recycling, ns) + insertNode(parent, toFragment(movable), nextSibling) + old[oldIndex].skip = true + if (movable.dom != null) nextSibling = movable.dom + } + else { + var dom = createNode(v, hooks, undefined) + insertNode(parent, dom, nextSibling) + nextSibling = dom + } + } + end-- + } + if (end < start) break + } + createNodes(parent, vnodes, start, end + 1, hooks, nextSibling, ns) + removeNodes(old, oldStart, oldEnd + 1, vnodes) } } function updateNode(parent, old, vnode, hooks, nextSibling, recycling, ns) { @@ -1042,9 +1043,11 @@ var coreRouter = function($window) { var hash = buildQueryString(hashData) if (hash) path += "#" + hash if (supportsPushState) { - if (options && options.replace) $window.history.replaceState(null, null, router.prefix + path) - else $window.history.pushState(null, null, router.prefix + path) + var state = options ? options.state : null + var title = options ? options.title : null $window.onpopstate() + if (options && options.replace) $window.history.replaceState(state, title, router.prefix + path) + else $window.history.pushState(state, title, router.prefix + path) } else $window.location.href = router.prefix + path } @@ -1054,6 +1057,10 @@ var coreRouter = function($window) { var params = {} var pathname = parsePath(path, params, params) + var state = $window.history.state + if (state != null) { + for (var k in state) params[k] = state[k] + } for (var route0 in routes) { var matcher = new RegExp("^" + route0.replace(/:[^\/]+?\.{3}/g, "(.*?)").replace(/:[^\/]+/g, "([^\\/]+)") + "\/?$") if (matcher.test(pathname)) { diff --git a/mithril.min.js b/mithril.min.js index d4facde4..524cb9d3 100644 --- a/mithril.min.js +++ b/mithril.min.js @@ -1,41 +1,41 @@ -new function(){function w(a,c,h,d,g,m){return{tag:a,key:c,attrs:h,children:d,text:g,dom:m,domSize:void 0,state:{},events:void 0,instance:void 0,skip:!1}}function z(a){if(null==a||"string"!==typeof a&&null==a.view)throw Error("The selector must be either a string or a component.");if("string"===typeof a&&void 0===K[a]){for(var c,h,d=[],g={};c=P.exec(a);){var m=c[1],l=c[2];""===m&&""!==l?h=l:"#"===m?g.id=l:"."===m?d.push(l):"["===c[3][0]&&((m=c[6])&&(m=m.replace(/\\(["'])/g,"$1").replace(/\\\\/g,"\\")), -"class"===c[4]?d.push(m):g[c[4]]=m||!0)}0a.indexOf("?")?"?":"&";a+=d+b}return a}function l(a){try{return""!==a?JSON.parse(a):null}catch(D){throw Error(a);}}function n(a){return a.responseText}function p(a, -c){if("function"===typeof a)if(c instanceof Array)for(var b=0;bk.status||304===k.status)c(p(b.type,a));else{var g=Error(k.responseText),h;for(h in a)g[h]=a[h];d(g)}}catch(F){d(F)}};h&&null!=b.data?k.send(b.data):k.send()});return!0===b.background?t:r(t)},jsonp:function(b,l){var n=h();b=d(b,l);var t=new c(function(c, -d){var h=b.callbackName||"_mithril_"+Math.round(1E16*Math.random())+"_"+k++,l=a.document.createElement("script");a[h]=function(d){l.parentNode.removeChild(l);c(p(b.type,d));delete a[h]};l.onerror=function(){l.parentNode.removeChild(l);d(Error("JSONP request failed"));delete a[h]};null==b.data&&(b.data={});b.url=g(b.url,b.data);b.data[b.callbackKey||"callback"]=h;l.src=m(b.url,b.data);a.document.documentElement.appendChild(l)});return!0===b.background?t:n(t)},setCompletionCallback:function(a){r=a}}}(window, -H),O=function(a){function c(e,f,a,b,c,d,g){for(;a=k&&t>=A;){var x=f[k],u=a[A];if(x!==u||q)if(null==x)k++;else if(null==u)A++;else if(x.key===u.key)k++,A++,m(e,x,u,b,n(f,k,d),q,g),q&&x.tag===u.tag&&p(e,l(x),d);else if(x=f[y],x!==u||q)if(null==x)y--;else if(null==u)A++;else if(x.key===u.key)m(e,x,u,b,n(f,y+1,d),q,g),(q||A=k&&t>=A;){x=f[y];u=a[t];if(x!==u||q)if(null==x)y--;else{if(null!= -u)if(x.key===u.key)m(e,x,u,b,n(f,y+1,d),q,g),q&&x.tag===u.tag&&p(e,l(x),d),null!=x.dom&&(d=x.dom),y--;else{if(!D){D=f;var x=y,C={},w;for(w=0;wa.indexOf("?")?"?":"&";a+=d+b}return a}function k(a){try{return""!==a?JSON.parse(a):null}catch(C){throw Error(a);}}function m(a){return a.responseText}function q(a, +c){if("function"===typeof a)if(c instanceof Array)for(var b=0;bp.status||304===p.status)c(q(b.type,a));else{var g=Error(p.responseText),h;for(h in a)g[h]=a[h];d(g)}}catch(F){d(F)}};h&&null!=b.data?p.send(b.data):p.send()});return!0===b.background?x:t(x)},jsonp:function(b,k){var m=h();b=d(b,k);var t=new c(function(c, +d){var h=b.callbackName||"_mithril_"+Math.round(1E16*Math.random())+"_"+r++,k=a.document.createElement("script");a[h]=function(d){k.parentNode.removeChild(k);c(q(b.type,d));delete a[h]};k.onerror=function(){k.parentNode.removeChild(k);d(Error("JSONP request failed"));delete a[h]};null==b.data&&(b.data={});b.url=g(b.url,b.data);b.data[b.callbackKey||"callback"]=h;k.src=l(b.url,b.data);a.document.documentElement.appendChild(k)});return!0===b.background?t:m(t)},setCompletionCallback:function(a){t=a}}}(window, +H),O=function(a){function c(e,f,a,b,c,d,g){for(;a=v&&x>=A;){var p=f[v],n=a[A];if(p!==n||u)if(null==p)v++;else if(null==n)A++;else if(p.key===n.key)v++,A++,l(e,p,n,b,m(f,v,d),u,g),u&&p.tag===n.tag&&q(e,k(p),d);else if(p=f[r],p!==n||u)if(null==p)r--;else if(null==n)A++;else if(p.key===n.key)l(e,p,n,b,m(f,r+1,d),u,g),(u||A=v&&x>=A;){p=f[r];n=a[x]; +if(p!==n||u)if(null==p)r--;else{if(null!=n)if(p.key===n.key)l(e,p,n,b,m(f,r+1,d),u,g),u&&p.tag===n.tag&&q(e,k(p),d),null!=p.dom&&(d=p.dom),r--;else{if(!C){C=f;var p=r,E={},w;for(w=0;w= oldStart && end >= start) { - var o = old[oldStart], v = vnodes[start] - if (o === v && !recycling) oldStart++, start++ - else if (o == null) oldStart++ + var oldStart = 0, start = 0, oldEnd = old.length - 1, end = vnodes.length - 1, map + while (oldEnd >= oldStart && end >= start) { + var o = old[oldStart], v = vnodes[start] + if (o === v && !recycling) oldStart++, start++ + else if (o == null) oldStart++ + else if (v == null) start++ + else if (o.key === v.key) { + oldStart++, start++ + updateNode(parent, o, v, hooks, getNextSibling(old, oldStart, nextSibling), recycling, ns) + if (recycling && o.tag === v.tag) insertNode(parent, toFragment(o), nextSibling) + } + else { + var o = old[oldEnd] + if (o === v && !recycling) oldEnd--, start++ + else if (o == null) oldEnd-- else if (v == null) start++ - else if (o.key === v.key) { - oldStart++, start++ - updateNode(parent, o, v, hooks, getNextSibling(old, oldStart, nextSibling), recycling, ns) - if (recycling && o.tag === v.tag) insertNode(parent, toFragment(o), nextSibling) - } - else { - var o = old[oldEnd] - if (o === v && !recycling) oldEnd--, start++ - else if (o == null) oldEnd-- - else if (v == null) start++ - else if (o.key === v.key) { - updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), recycling, ns) - if (recycling || start < end) insertNode(parent, toFragment(o), getNextSibling(old, oldStart, nextSibling)) - oldEnd--, start++ - } - else break - } - } - while (oldEnd >= oldStart && end >= start) { - var o = old[oldEnd], v = vnodes[end] - if (o === v && !recycling) oldEnd--, end-- - else if (o == null) oldEnd-- - else if (v == null) end-- else if (o.key === v.key) { updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), recycling, ns) - if (recycling && o.tag === v.tag) insertNode(parent, toFragment(o), nextSibling) - if (o.dom != null) nextSibling = o.dom - oldEnd--, end-- + if (recycling || start < end) insertNode(parent, toFragment(o), getNextSibling(old, oldStart, nextSibling)) + oldEnd--, start++ } - else { - if (!map) map = getKeyMap(old, oldEnd) - if (v != null) { - var oldIndex = map[v.key] - if (oldIndex != null) { - var movable = old[oldIndex] - updateNode(parent, movable, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), recycling, ns) - insertNode(parent, toFragment(movable), nextSibling) - old[oldIndex].skip = true - if (movable.dom != null) nextSibling = movable.dom - } - else { - var dom = createNode(v, hooks, undefined) - insertNode(parent, dom, nextSibling) - nextSibling = dom - } - } - end-- - } - if (end < start) break + else break } - createNodes(parent, vnodes, start, end + 1, hooks, nextSibling, ns) - removeNodes(old, oldStart, oldEnd + 1, vnodes) } + while (oldEnd >= oldStart && end >= start) { + var o = old[oldEnd], v = vnodes[end] + if (o === v && !recycling) oldEnd--, end-- + else if (o == null) oldEnd-- + else if (v == null) end-- + else if (o.key === v.key) { + updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), recycling, ns) + if (recycling && o.tag === v.tag) insertNode(parent, toFragment(o), nextSibling) + if (o.dom != null) nextSibling = o.dom + oldEnd--, end-- + } + else { + if (!map) map = getKeyMap(old, oldEnd) + if (v != null) { + var oldIndex = map[v.key] + if (oldIndex != null) { + var movable = old[oldIndex] + updateNode(parent, movable, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), recycling, ns) + insertNode(parent, toFragment(movable), nextSibling) + old[oldIndex].skip = true + if (movable.dom != null) nextSibling = movable.dom + } + else { + var dom = createNode(v, hooks, undefined) + insertNode(parent, dom, nextSibling) + nextSibling = dom + } + } + end-- + } + if (end < start) break + } + createNodes(parent, vnodes, start, end + 1, hooks, nextSibling, ns) + removeNodes(old, oldStart, oldEnd + 1, vnodes) } } function updateNode(parent, old, vnode, hooks, nextSibling, recycling, ns) { diff --git a/render/tests/test-updateNodes.js b/render/tests/test-updateNodes.js index 524db029..b0e8c337 100644 --- a/render/tests/test-updateNodes.js +++ b/render/tests/test-updateNodes.js @@ -880,7 +880,7 @@ o.spec("updateNodes", function() { o(onupdate.callCount).equals(0) }) o("cached, keyed nodes skip diff", function () { - var onupdate = o.spy(); + var onupdate = o.spy() var cached = {tag:"a", key:"a", attrs:{onupdate: onupdate}} render(root, cached) @@ -926,4 +926,14 @@ o.spec("updateNodes", function() { o(update.callCount).equals(2) o(remove.callCount).equals(0) }) + o("component is recreated if key changes to undefined", function () { + var vnode = {tag: "b", key: 1} + var updated = {tag: "b"} + + render(root, vnode) + var dom = vnode.dom + render(root, updated) + + o(vnode.dom).notEquals(updated.dom) + }) }) diff --git a/router/router.js b/router/router.js index 6e1d7270..51961e99 100644 --- a/router/router.js +++ b/router/router.js @@ -67,9 +67,11 @@ module.exports = function($window) { if (hash) path += "#" + hash if (supportsPushState) { - if (options && options.replace) $window.history.replaceState(null, null, router.prefix + path) - else $window.history.pushState(null, null, router.prefix + path) + var state = options ? options.state : null + var title = options ? options.title : null $window.onpopstate() + if (options && options.replace) $window.history.replaceState(state, title, router.prefix + path) + else $window.history.pushState(state, title, router.prefix + path) } else $window.location.href = router.prefix + path } @@ -79,6 +81,10 @@ module.exports = function($window) { var params = {} var pathname = parsePath(path, params, params) + var state = $window.history.state + if (state != null) { + for (var k in state) params[k] = state[k] + } for (var route in routes) { var matcher = new RegExp("^" + route.replace(/:[^\/]+?\.{3}/g, "(.*?)").replace(/:[^\/]+/g, "([^\\/]+)") + "\/?$") diff --git a/router/tests/test-setPath.js b/router/tests/test-setPath.js index c957a59a..d18c7525 100644 --- a/router/tests/test-setPath.js +++ b/router/tests/test-setPath.js @@ -150,6 +150,18 @@ o.spec("Router.setPath", function() { o($window.location.href).equals(env.protocol + "//" + (env.hostname === "/" ? "" : env.hostname) + slash + (prefix ? prefix + "/" : "") + "test") + done() + }) + }) + o("state works", function(done) { + $window.location.href = prefix + "/test" + router.defineRoutes({"/test": {data: 1}, "/other": {data: 2}}, onRouteChange, onFail) + + callAsync(function() { + router.setPath("/other", null, {state: {a: 1}}) + + o($window.history.state).deepEquals({a: 1}) + done() }) }) diff --git a/test-utils/pushStateMock.js b/test-utils/pushStateMock.js index d23b3a1f..91e7a136 100644 --- a/test-utils/pushStateMock.js +++ b/test-utils/pushStateMock.js @@ -13,7 +13,7 @@ module.exports = function(options) { var search = "" var hash = "" - var past = [], future = [] + var past = [{url: getURL(), isNew: true, state: null, title: null}], future = [] function getURL() { if (protocol === "file:") return protocol + "//" + pathname + search + hash @@ -42,7 +42,7 @@ module.exports = function(options) { if (typeof $window.onhashchange === "function") $window.onhashchange({type: "hashchange"}) } function popstate() { - if (typeof $window.onpopstate === "function") $window.onpopstate({type: "popstate"}) + if (typeof $window.onpopstate === "function") $window.onpopstate({type: "popstate", state: $window.history.state}) } function unload() { if (typeof $window.onunload === "function") $window.onunload({type: "unload"}) @@ -135,20 +135,22 @@ module.exports = function(options) { }, } $window.history = { - pushState: function(data, title, url) { - past.push({url: getURL(), isNew: false}) + pushState: function(state, title, url) { + past.push({url: getURL(), isNew: false, state: state, title: title}) future = [] setURL(url) }, - replaceState: function(data, title, url) { - future = [] + replaceState: function(state, title, url) { + var entry = past[past.length - 1] + entry.state = state + entry.title = title setURL(url) }, back: function() { - var entry = past.pop() - if (entry != null) { + if (past.length > 1) { + var entry = past.pop() if (entry.isNew) unload() - future.push({url: getURL(), isNew: false}) + future.push({url: getURL(), isNew: false, state: entry.state, title: entry.title}) setURL(entry.url) if (!entry.isNew) popstate() } @@ -157,11 +159,14 @@ module.exports = function(options) { var entry = future.pop() if (entry != null) { if (entry.isNew) unload() - past.push({url: getURL(), isNew: false}) + past.push({url: getURL(), isNew: false, state: entry.state, title: entry.title}) setURL(entry.url) if (!entry.isNew) popstate() } }, + get state() { + return past.length === 0 ? null : past[past.length - 1].state + }, } $window.onpopstate = null, $window.onhashchange = null, diff --git a/test-utils/tests/test-pushStateMock.js b/test-utils/tests/test-pushStateMock.js index fa9c064f..86298b30 100644 --- a/test-utils/tests/test-pushStateMock.js +++ b/test-utils/tests/test-pushStateMock.js @@ -417,6 +417,71 @@ o.spec("pushStateMock", function() { $window.history.pushState(null, null, "b") $window.history.back() }) + o("replaceState does not break forward history", function() { + $window.onpopstate = o.spy() + + $window.history.pushState(null, null, "b") + $window.history.back() + + o($window.onpopstate.callCount).equals(1) + o($window.location.href).equals("http://localhost/") + + $window.history.replaceState(null, null, "a") + + o($window.location.href).equals("http://localhost/a") + + $window.history.forward() + + o($window.onpopstate.callCount).equals(2) + o($window.location.href).equals("http://localhost/b") + }) + o("pushstate retains state", function() { + $window.onpopstate = o.spy() + + $window.history.pushState({a: 1}, null, "#a") + $window.history.pushState({b: 2}, null, "#b") + + o($window.onpopstate.callCount).equals(0) + + $window.history.back() + + o($window.onpopstate.callCount).equals(1) + o($window.onpopstate.args[0].type).equals("popstate") + o($window.onpopstate.args[0].state).deepEquals({a: 1}) + + $window.history.back() + + o($window.onpopstate.callCount).equals(2) + o($window.onpopstate.args[0].type).equals("popstate") + o($window.onpopstate.args[0].state).equals(null) + + $window.history.forward() + + o($window.onpopstate.callCount).equals(3) + o($window.onpopstate.args[0].type).equals("popstate") + o($window.onpopstate.args[0].state).deepEquals({a: 1}) + + $window.history.forward() + + o($window.onpopstate.callCount).equals(4) + o($window.onpopstate.args[0].type).equals("popstate") + o($window.onpopstate.args[0].state).deepEquals({b: 2}) + }) + o("replacestate replaces state", function() { + $window.onpopstate = o.spy(pop) + + $window.history.replaceState({a: 1}, null, "a") + + o($window.history.state).deepEquals({a: 1}) + + $window.history.pushState(null, null, "a") + $window.history.back() + + function pop(e) { + o(e.state).deepEquals({a: 1}) + o($window.history.state).deepEquals({a: 1}) + } + }) }) o.spec("onhashchance", function() { o("onhashchange triggers on location.href change", function() { From 15c28066d7b8a1a702144ec37aecd2bd4b8b68b9 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 18 Dec 2016 22:07:33 +1100 Subject: [PATCH 10/15] Spy on setAttribute to test customElement support --- render/render.js | 7 ++-- render/tests/test-attributes.js | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/render/render.js b/render/render.js index 8d9bd366..731ebdf3 100644 --- a/render/render.js +++ b/render/render.js @@ -435,14 +435,14 @@ module.exports = function($window) { } function setAttr(vnode, key, old, value, ns) { var element = vnode.dom - if (key === "key" || (old === value && !isFormAttribute(vnode, key)) && typeof value !== "object" || typeof value === "undefined" || isLifecycleMethod(key)) return + if (key === "key" || key === "is" || (old === value && !isFormAttribute(vnode, key)) && typeof value !== "object" || typeof value === "undefined" || isLifecycleMethod(key)) return var nsLastIndex = key.indexOf(":") if (nsLastIndex > -1 && key.substr(0, nsLastIndex) === "xlink") { element.setAttributeNS("http://www.w3.org/1999/xlink", key.slice(nsLastIndex + 1), value) } else if (key[0] === "o" && key[1] === "n" && typeof value === "function") updateEvent(vnode, key, value) else if (key === "style") updateStyle(element, old, value) - else if (key in element && !isAttribute(key) && ns === undefined) { + else if (key in element && !isAttribute(key) && ns === undefined && !isCustomElement(vnode)) { //setting input[value] to same value by typing on focused element moves cursor to end in Chrome if (vnode.tag === "input" && key === "value" && vnode.dom.value === value && vnode.dom === $doc.activeElement) return //setting select[value] to same value while having select open blinks select dropdown in Chrome @@ -491,6 +491,9 @@ module.exports = function($window) { function isAttribute(attr) { return attr === "href" || attr === "list" || attr === "form" || attr === "width" || attr === "height"// || attr === "type" } + function isCustomElement(vnode){ + return vnode.attrs.is || vnode.tag.indexOf("-") > -1 + } function hasIntegrationMethods(source) { return source != null && (source.oncreate || source.onupdate || source.onbeforeremove || source.onremove) } diff --git a/render/tests/test-attributes.js b/render/tests/test-attributes.js index 73cbd685..8159d0d3 100644 --- a/render/tests/test-attributes.js +++ b/render/tests/test-attributes.js @@ -11,7 +11,68 @@ o.spec("attributes", function() { root = $window.document.body render = vdom($window).render }) + o.spec("customElements", function(){ + + o("when vnode is customElement, custom setAttribute called", function(){ + var normalFirstDraw = [ + { tag: "input", attrs: { wow: 'text' } }, + { tag: "input", attrs: { wow: 'text' } }, + { tag: "input", attrs: { wow: 'text' } }, + ] + + var normalSecondDraw = [ + { tag: "input", attrs: { wow: 'text' } }, + { tag: "input", attrs: { wow: 'text' } }, + { tag: "input", attrs: { wow: 'text' } }, + ] + + var customFirstDraw = [ + { tag: "custom-element", attrs: { custom: 'x' } }, + { tag: "input", attrs: { is: 'something-special', custom: 'x' } }, + { tag: "custom-element", attrs: { is: 'something-special', custom: 'x' } } + ] + + var customSecondDraw = [ + { tag: "custom-element", attrs: { custom: 'y' } }, + { tag: "input", attrs: { is: 'something-special', custom: 'y' } }, + { tag: "custom-element", attrs: { is: 'something-special', custom: 'y' } } + ] + + var draws = [ + normalFirstDraw, normalSecondDraw, + customFirstDraw, customSecondDraw + ] + + var customRedraws = 2 + var customSetAttrCalls = customFirstDraw.length * customRedraws; + var innerHTMLCalls = normalFirstDraw.length + + var f = $window.document.createElement + var spy + + $window.document.createElement = function(tag, is){ + var el = f(tag, is) + if(!spy){ + spy = o.spy(el.setAttribute) + } + el.setAttribute = spy + + return el + } + + draws.forEach( + function(view) { + render(root, view) + } + ) + + o(spy.callCount).equals( + customSetAttrCalls + innerHTMLCalls + ) + }) + + }) o.spec("input readonly", function() { o("when input readonly is true, attribute is present", function() { var a = {tag: "input", attrs: {readonly: true}} From 39b6f1e0feddcb97ec43e89e3d50e9da83d80911 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 18 Dec 2016 22:21:53 +1100 Subject: [PATCH 11/15] Simplify draw routine --- render/tests/test-attributes.js | 43 +++++++-------------------------- 1 file changed, 9 insertions(+), 34 deletions(-) diff --git a/render/tests/test-attributes.js b/render/tests/test-attributes.js index 8159d0d3..2d08007b 100644 --- a/render/tests/test-attributes.js +++ b/render/tests/test-attributes.js @@ -15,38 +15,19 @@ o.spec("attributes", function() { o("when vnode is customElement, custom setAttribute called", function(){ - var normalFirstDraw = [ - { tag: "input", attrs: { wow: 'text' } }, - { tag: "input", attrs: { wow: 'text' } }, - { tag: "input", attrs: { wow: 'text' } }, + var normal = [ + { tag: "input", attrs: { value: 'hello' } }, + { tag: "input", attrs: { value: 'hello' } }, + { tag: "input", attrs: { value: 'hello' } } ] - - var normalSecondDraw = [ - { tag: "input", attrs: { wow: 'text' } }, - { tag: "input", attrs: { wow: 'text' } }, - { tag: "input", attrs: { wow: 'text' } }, - ] - - var customFirstDraw = [ + + var custom = [ { tag: "custom-element", attrs: { custom: 'x' } }, { tag: "input", attrs: { is: 'something-special', custom: 'x' } }, { tag: "custom-element", attrs: { is: 'something-special', custom: 'x' } } ] - var customSecondDraw = [ - { tag: "custom-element", attrs: { custom: 'y' } }, - { tag: "input", attrs: { is: 'something-special', custom: 'y' } }, - { tag: "custom-element", attrs: { is: 'something-special', custom: 'y' } } - ] - - var draws = [ - normalFirstDraw, normalSecondDraw, - customFirstDraw, customSecondDraw - ] - - var customRedraws = 2 - var customSetAttrCalls = customFirstDraw.length * customRedraws; - var innerHTMLCalls = normalFirstDraw.length + var view = normal.concat(custom) var f = $window.document.createElement var spy @@ -61,15 +42,9 @@ o.spec("attributes", function() { return el } - draws.forEach( - function(view) { - render(root, view) - } - ) + render(root, view) - o(spy.callCount).equals( - customSetAttrCalls + innerHTMLCalls - ) + o(spy.callCount).equals( custom.length ) }) }) From 0bab044bba9221579d4835e6ad0b71a7a489e360 Mon Sep 17 00:00:00 2001 From: Gandalf-the-Bot Date: Sun, 18 Dec 2016 15:34:16 +0000 Subject: [PATCH 12/15] Bundled output for commit 23ce6031299aebf58d101bb4d5477ad51f2da162 [skip ci] --- README.md | 2 +- mithril.js | 7 +++++-- mithril.min.js | 20 ++++++++++---------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ac65b7f1..9664d8f4 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,6 @@ There are over 4000 assertions in the test suite, and tests cover even difficult ## Modularity -Despite the huge improvements in performance and modularity, the new codebase is smaller than v0.2.x, currently clocking at 7.53 KB min+gzip +Despite the huge improvements in performance and modularity, the new codebase is smaller than v0.2.x, currently clocking at 7.54 KB min+gzip In addition, Mithril is now completely modular: you can import only the modules that you need and easily integrate 3rd party modules if you wish to use a different library for routing, ajax, and even rendering diff --git a/mithril.js b/mithril.js index 43523644..715ecc63 100644 --- a/mithril.js +++ b/mithril.js @@ -759,14 +759,14 @@ var coreRenderer = function($window) { } function setAttr(vnode, key2, old, value, ns) { var element = vnode.dom - if (key2 === "key" || (old === value && !isFormAttribute(vnode, key2)) && typeof value !== "object" || typeof value === "undefined" || isLifecycleMethod(key2)) return + if (key2 === "key" || key2 === "is" || (old === value && !isFormAttribute(vnode, key2)) && typeof value !== "object" || typeof value === "undefined" || isLifecycleMethod(key2)) return var nsLastIndex = key2.indexOf(":") if (nsLastIndex > -1 && key2.substr(0, nsLastIndex) === "xlink") { element.setAttributeNS("http://www.w3.org/1999/xlink", key2.slice(nsLastIndex + 1), value) } else if (key2[0] === "o" && key2[1] === "n" && typeof value === "function") updateEvent(vnode, key2, value) else if (key2 === "style") updateStyle(element, old, value) - else if (key2 in element && !isAttribute(key2) && ns === undefined) { + else if (key2 in element && !isAttribute(key2) && ns === undefined && !isCustomElement(vnode)) { //setting input[value] to same value by typing on focused element moves cursor to end in Chrome if (vnode.tag === "input" && key2 === "value" && vnode.dom.value === value && vnode.dom === $doc.activeElement) return //setting select[value] to same value while having select open blinks select dropdown in Chrome @@ -815,6 +815,9 @@ var coreRenderer = function($window) { function isAttribute(attr) { return attr === "href" || attr === "list" || attr === "form" || attr === "width" || attr === "height"// || attr === "type" } + function isCustomElement(vnode){ + return vnode.attrs.is || vnode.tag.indexOf("-") > -1 + } function hasIntegrationMethods(source) { return source != null && (source.oncreate || source.onupdate || source.onbeforeremove || source.onremove) } diff --git a/mithril.min.js b/mithril.min.js index 5242bdcd..811aaac4 100644 --- a/mithril.min.js +++ b/mithril.min.js @@ -26,16 +26,16 @@ null!=b.text&&(b.children=[u("#",void 0,void 0,b.text,void 0,void 0)]),g(q,f.chi b.dom){var e=y.createDocumentFragment();if(0 Date: Tue, 20 Dec 2016 11:15:10 -0500 Subject: [PATCH 13/15] beef up the docs about differences between route resolvers and components --- docs/route.md | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/docs/route.md b/docs/route.md index 5ce6d1cd..c443d418 100644 --- a/docs/route.md +++ b/docs/route.md @@ -390,31 +390,76 @@ In the example above, the layout merely consists of a `
` tha One way to wrap the layout is to define an anonymous component in the routes map: ```javascript +// example 1 m.route(document.body, "/", { "/": { view: function() { return m(Layout, m(Home)) }, + }, + "/form": { + view: function() { + return m(Layout, m(Form)) + }, } }) ``` -However, note that because the top level component is an anonymous component, jumping from route to route will tear down the anonymous component and recreate the DOM from scratch. If the Layout component had [lifecycle methods](lifecycle-methods.md) defined, the `oninit` and `oncreate` hooks would fire on every route change. Depending on the application, this may or may not be desirable. +However, note that because the top level component is an anonymous component, jumping from the `/` route to the `/form` route (or vice-versa) will tear down the anonymous component and recreate the DOM from scratch. If the Layout component had [lifecycle methods](lifecycle-methods.md) defined, the `oninit` and `oncreate` hooks would fire on every route change. Depending on the application, this may or may not be desirable. If you would prefer to have the Layout component be diffed and maintained intact rather than recreated from scratch, you should instead use a RouteResolver as the root object: ```javascript +// example 2 m.route(document.body, "/", { "/": { render: function() { return m(Layout, m(Home)) }, + }, + "/form": { + render: function() { + return m(Layout, m(Form)) + }, } }) ``` Note that in this case, if the Layout component the `oninit` and `oncreate` lifecycle methods would only fire on the Layout component on the first route change (assuming all routes use the same layout). +To clarify the difference between the two examples, example 1 is equivalent to this code: + +```javascript +// functionally equivalent to example 1 +var Anon1 = { + view: function() { + return m(Layout, m(Home)) + }, +} +var Anon2 = { + view: function() { + return m(Layout, m(Form)) + }, +} + +m.route(document.body, "/", { + "/": { + render: function() { + return m(Anon1) + } + }, + "/form": { + render: function() { + return m(Anon2) + } + }, +}) +``` + +Since `Anon1` and `Anon2` are different components, their subtrees (including `Layout`) are recreated from scratch. This is also what happens when components are used directly without a RouteResolver. + +In example 2, since `Layout` is the top-level component in both routes, the DOM for the `Layout` component is diffed (i.e. left intact if it has no changes), and only the change from `Home` to `Form` triggers a recreation of that subsection of the DOM. + --- #### Authentication From f72aa50f27b0bc29144b776b7066106ef7239646 Mon Sep 17 00:00:00 2001 From: Pat Cavit Date: Tue, 20 Dec 2016 21:19:16 -0800 Subject: [PATCH 14/15] Expose `Vnode` as `m.vnode` (#1487) --- index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/index.js b/index.js index d849a317..6e69cea4 100644 --- a/index.js +++ b/index.js @@ -16,5 +16,6 @@ m.jsonp = requestService.jsonp m.parseQueryString = require("./querystring/parse") m.buildQueryString = require("./querystring/build") m.version = "bleeding-edge" +m.vnode = require("./render/vnode") module.exports = m From bc01dad847f02aa1636fe1aba6057d6b338f02d6 Mon Sep 17 00:00:00 2001 From: Gandalf-the-Bot Date: Wed, 21 Dec 2016 05:20:06 +0000 Subject: [PATCH 15/15] Bundled output for commit f72aa50f27b0bc29144b776b7066106ef7239646 [skip ci] --- README.md | 2 +- mithril.js | 1 + mithril.min.js | 32 ++++++++++++++++---------------- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 9664d8f4..913fdfe3 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,6 @@ There are over 4000 assertions in the test suite, and tests cover even difficult ## Modularity -Despite the huge improvements in performance and modularity, the new codebase is smaller than v0.2.x, currently clocking at 7.54 KB min+gzip +Despite the huge improvements in performance and modularity, the new codebase is smaller than v0.2.x, currently clocking at 7.55 KB min+gzip In addition, Mithril is now completely modular: you can import only the modules that you need and easily integrate 3rd party modules if you wish to use a different library for routing, ajax, and even rendering diff --git a/mithril.js b/mithril.js index 715ecc63..a27e1734 100644 --- a/mithril.js +++ b/mithril.js @@ -1153,6 +1153,7 @@ m.jsonp = requestService.jsonp m.parseQueryString = parseQueryString m.buildQueryString = buildQueryString m.version = "1.0.0-rc.6" +m.vnode = Vnode if (typeof module !== "undefined") module["exports"] = m else window.m = m } \ No newline at end of file diff --git a/mithril.min.js b/mithril.min.js index 811aaac4..f31212e4 100644 --- a/mithril.min.js +++ b/mithril.min.js @@ -1,41 +1,41 @@ -new function(){function u(a,c,h,d,g,m){return{tag:a,key:c,attrs:h,children:d,text:g,dom:m,domSize:void 0,state:{},events:void 0,instance:void 0,skip:!1}}function A(a){if(null==a||"string"!==typeof a&&null==a.view)throw Error("The selector must be either a string or a component.");if("string"===typeof a&&void 0===J[a]){for(var c,h,d=[],g={};c=P.exec(a);){var m=c[1],l=c[2];""===m&&""!==l?h=l:"#"===m?g.id=l:"."===m?d.push(l):"["===c[3][0]&&((m=c[6])&&(m=m.replace(/\\(["'])/g,"$1").replace(/\\\\/g,"\\")), +new function(){function u(a,c,h,d,g,m){return{tag:a,key:c,attrs:h,children:d,text:g,dom:m,domSize:void 0,state:{},events:void 0,instance:void 0,skip:!1}}function z(a){if(null==a||"string"!==typeof a&&null==a.view)throw Error("The selector must be either a string or a component.");if("string"===typeof a&&void 0===J[a]){for(var c,h,d=[],g={};c=P.exec(a);){var m=c[1],l=c[2];""===m&&""!==l?h=l:"#"===m?g.id=l:"."===m?d.push(l):"["===c[3][0]&&((m=c[6])&&(m=m.replace(/\\(["'])/g,"$1").replace(/\\\\/g,"\\")), "class"===c[4]?d.push(m):g[c[4]]=m||!0)}0a.indexOf("?")?"?":"&";a+=d+b}return a}function l(a){try{return""!==a?JSON.parse(a):null}catch(C){throw Error(a);}}function n(a){return a.responseText}function p(a, c){if("function"===typeof a)if(c instanceof Array)for(var b=0;bk.status||304===k.status)c(p(b.type,a));else{var g=Error(k.responseText),h;for(h in a)g[h]=a[h];d(g)}}catch(E){d(E)}};h&&null!=b.data?k.send(b.data):k.send()});return!0===b.background?w:t(w)},jsonp:function(b,l){var n=h();b=d(b,l);var t=new c(function(c, d){var h=b.callbackName||"_mithril_"+Math.round(1E16*Math.random())+"_"+k++,l=a.document.createElement("script");a[h]=function(d){l.parentNode.removeChild(l);c(p(b.type,d));delete a[h]};l.onerror=function(){l.parentNode.removeChild(l);d(Error("JSONP request failed"));delete a[h]};null==b.data&&(b.data={});b.url=g(b.url,b.data);b.data[b.callbackKey||"callback"]=h;l.src=m(b.url,b.data);a.document.documentElement.appendChild(l)});return!0===b.background?t:n(t)},setCompletionCallback:function(a){t=a}}}(window, -G),O=function(a){function c(e,f,a,b,c,d,g){for(;a=k&&w>=B;){var v=f[k],q=a[B];if(v!==q||r)if(null==v)k++;else if(null==q)B++;else if(v.key===q.key)k++,B++,m(e,v,q,b,n(f,k,d),r,g),r&&v.tag===q.tag&&p(e,l(v),d);else if(v=f[x],v!==q||r)if(null==v)x--;else if(null==q)B++;else if(v.key===q.key)m(e,v,q,b,n(f,x+1,d),r,g),(r||B=k&&w>=B;){v=f[x];q=a[w]; if(v!==q||r)if(null==v)x--;else{if(null!=q)if(v.key===q.key)m(e,v,q,b,n(f,x+1,d),r,g),r&&v.tag===q.tag&&p(e,l(v),d),null!=v.dom&&(d=v.dom),x--;else{if(!C){C=f;var v=x,y={},F;for(F=0;F