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
diff --git a/README.md b/README.md
index 11a87ed7..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.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.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/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 7ea00195..7e2454cb 100644
--- a/api/tests/test-router.js
+++ b/api/tests/test-router.js
@@ -932,6 +932,63 @@ o.spec("route", function() {
})
})
+ 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(){
+ 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")})
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).
diff --git a/docs/route.md b/docs/route.md
index 2d4112cc..c443d418 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)
@@ -39,6 +42,8 @@ m.route(document.body, "/home", {
})
```
+You can only have one `m.route` call per application.
+
---
### Signature
@@ -68,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
@@ -252,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:
@@ -262,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
@@ -323,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
@@ -407,7 +519,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"
}
@@ -433,7 +545,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/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
diff --git a/mithril.js b/mithril.js
index e5558df4..a27e1734 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) 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) {
@@ -758,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
@@ -814,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)
}
@@ -1042,9 +1046,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 +1060,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)) {
@@ -1081,14 +1091,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 +1101,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}
@@ -1143,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 69e76332..f31212e4 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;ba.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;wk.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= 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) {
@@ -434,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
@@ -490,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..2d08007b 100644
--- a/render/tests/test-attributes.js
+++ b/render/tests/test-attributes.js
@@ -11,7 +11,43 @@ 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 normal = [
+ { tag: "input", attrs: { value: 'hello' } },
+ { tag: "input", attrs: { value: 'hello' } },
+ { tag: "input", attrs: { value: 'hello' } }
+ ]
+
+ 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 view = normal.concat(custom)
+
+ 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
+ }
+
+ render(root, view)
+
+ o(spy.callCount).equals( custom.length )
+ })
+
+ })
o.spec("input readonly", function() {
o("when input readonly is true, attribute is present", function() {
var a = {tag: "input", attrs: {readonly: true}}
diff --git a/render/tests/test-updateNodes.js b/render/tests/test-updateNodes.js
index 7b0f9715..b0e8c337 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}]
@@ -871,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)
@@ -917,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() {