From de07a5488150d7ffa9004bf2d28b0679fdb75695 Mon Sep 17 00:00:00 2001 From: Leo Horie Date: Sat, 17 Dec 2016 21:50:04 -0500 Subject: [PATCH] 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() {