diff --git a/.eslintrc.js b/.eslintrc.js index c14d0119..a38c5e1b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,7 +4,7 @@ module.exports = { "commonjs": true, "es6": true, "node": true - }, + }, "extends": "eslint:recommended", "rules": { "accessor-pairs": "error", diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ab8484ef..6485e4d6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,3 @@ -# Leo owns everything, for better or worse -* @lhorie - .travis.yml @tivac package.json @tivac .npmignore @tivac @@ -9,4 +6,4 @@ package.json @tivac README.md @tivac docs/ @tivac performance/ @tivac -render/ @isiahmeadows +render/ @isiahmeadows @pygy diff --git a/README.md b/README.md index 105894ed..e797acf0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ mithril.js [![NPM Version](https://img.shields.io/npm/v/mithril.svg)](https://ww ## What is Mithril? -A modern client-side Javascript framework for building Single Page Applications. It's small (8.43 KB gzipped), fast and provides routing and XHR utilities out of the box. +A modern client-side Javascript framework for building Single Page Applications. It's small (8.59 KB gzipped), fast and provides routing and XHR utilities out of the box. Mithril is used by companies like Vimeo and Nike, and open source platforms like Lichess 👍. diff --git a/api/router.js b/api/router.js index 40ccb7cd..4d893acc 100644 --- a/api/router.js +++ b/api/router.js @@ -52,7 +52,7 @@ module.exports = function($window, redrawService) { } route.get = function() {return currentPath} route.prefix = function(prefix) {routeService.prefix = prefix} - route.link = function(vnode) { + var link = function(options, vnode) { vnode.dom.setAttribute("href", routeService.prefix + vnode.attrs.href) vnode.dom.onclick = function(e) { if (e.ctrlKey || e.metaKey || e.shiftKey || e.which === 2) return @@ -60,9 +60,13 @@ module.exports = function($window, redrawService) { e.redraw = false var href = this.getAttribute("href") if (href.indexOf(routeService.prefix) === 0) href = href.slice(routeService.prefix.length) - route.set(href, undefined, undefined) + route.set(href, undefined, options) } } + route.link = function(args) { + if (args.tag == null) return link.bind(link, args) + return link({}, args) + } route.param = function(key) { if(typeof attrs !== "undefined" && typeof key !== "undefined") return attrs[key] return attrs diff --git a/api/tests/test-router.js b/api/tests/test-router.js index 69aff4ca..f5336625 100644 --- a/api/tests/test-router.js +++ b/api/tests/test-router.js @@ -281,6 +281,36 @@ o.spec("route", function() { o($window.location.href).equals(env.protocol + "//" + (env.hostname === "/" ? "" : env.hostname) + slash + (prefix ? prefix + "/" : "") + "test") }) + o("passes options on route.link", function() { + var opts = {} + var e = $window.document.createEvent("MouseEvents") + + e.initEvent("click", true, true) + $window.location.href = prefix + "/" + + route(root, "/", { + "/" : { + view: function() { + return m("a", { + href: "/test", + oncreate: route.link(opts) + }) + } + }, + "/test" : { + view : function() { + return m("div") + } + } + }) + route.set = o.spy(route.set) + + root.firstChild.dispatchEvent(e) + + o(route.set.callCount).equals(1) + o(route.set.args[2]).equals(opts) + }) + o("accepts RouteResolver with onmatch that returns Component", function(done) { var matchCount = 0 var renderCount = 0 diff --git a/bundler/minify.js b/bundler/minify.js index 51b2b775..3b1d3cae 100644 --- a/bundler/minify.js +++ b/bundler/minify.js @@ -1,6 +1,6 @@ "use strict" -var http = require("http") +var http = require("https") var querystring = require("querystring") var fs = require("fs") @@ -22,7 +22,6 @@ module.exports = function(input, output, options, done) { var response = "" var req = http.request({ method: "POST", - protocol: "http:", hostname: "closure-compiler.appspot.com", path: "/compile", headers: { @@ -33,8 +32,16 @@ module.exports = function(input, output, options, done) { res.on("data", function(chunk) { response += chunk.toString() }) + res.on("end", function() { - var results = JSON.parse(response) + try { + var results = JSON.parse(response) + } catch(e) { + console.error(response); + + throw e; + } + if (results.errors) { for (var i = 0; i < results.errors.length; i++) console.log(results.errors[i]) } diff --git a/docs/change-log.md b/docs/change-log.md index 3873c6b8..25e0a231 100644 --- a/docs/change-log.md +++ b/docs/change-log.md @@ -1,7 +1,9 @@ # Change log - [v2.0.0](#v200-wip) -- [v1.1.4](#v113) +- [v1.1.6](#v116) +- [v1.1.5](#v115) +- [v1.1.4](#v114) - [v1.1.3](#v113) - [v1.1.2](#v112) - [v1.1.1](#v111) @@ -18,27 +20,58 @@ - API: `m.redraw()` is always asynchronous ([#1592](https://github.com/MithrilJS/mithril.js/pull/1592)) - API: `m.mount()` will only render its own root when called, it will not trigger a `redraw()` ([#1592](https://github.com/MithrilJS/mithril.js/pull/1592)) +- API: Assigning to `vnode.state` (as in `vnode.state = ...`) is no longer supported. Instead, an error is thrown if `vnode.state` changes upon the invocation of a lifecycle hook. +- API: `m.request` will no longer reject the Promise on server errors (eg. status >= 400) if the caller supplies an `extract` callback. This gives applications more control over handling server responses. #### News - API: Introduction of `m.redraw.sync()` ([#1592](https://github.com/MithrilJS/mithril.js/pull/1592)) - API: Event handlers may also be objects with `handleEvent` methods ([#1939](https://github.com/MithrilJS/mithril.js/issues/1939)). +- API: `m.route.link` accepts an optional `options` object ([#1930](https://github.com/MithrilJS/mithril.js/pull/1930)) +- API: `m.request` supports `timeout` as attr - ([#1966](https://github.com/MithrilJS/mithril.js/pull/1966)) -#### Ospec improvements: - -- Added support for async functions and promises in tests - ([#1928](https://github.com/MithrilJS/mithril.js/pull/1928)) -- Error handling for async tests with `done` callbacks supports error as first argument #### Bug fixes - API: `m.route.set()` causes all mount points to be redrawn ([#1592](https://github.com/MithrilJS/mithril.js/pull/1592)) -- API: If a user sets the Content-Type header within a request's options, that value will be the entire header value rather than being appended to the default value ([#1924](https://github.com/MithrilJS/mithril.js/pull/1924)) - API: Using style objects in hyperscript calls will now properly diff style properties from one render to another as opposed to re-writing all element style properties every render. - core: `addEventListener` and `removeEventListener` are always used to manage event subscriptions, preventing external interference. - core: Event listeners allocate less memory, swap at low cost, and are properly diffed now when rendered via `m.mount()`/`m.redraw()`. - core: `Object.prototype` properties can no longer interfere with event listener calls. - API: Event handlers, when set to literally `undefined` (or any non-function), are now correctly removed. - core: `xlink:href` attributes are now correctly removed +- render: fixed an ommission that caused `oninit` to be called unnecessarily in some cases [#1992](https://github.com/MithrilJS/mithril.js/issues/1992) +- render: Render state correctly on select change event [#1916](https://github.com/MithrilJS/mithril.js/issues/1916) +- render: fix various updateNodes/removeNodes issues when the pool and fragments are involved [#1990](https://github.com/MithrilJS/mithril.js/issues/1990), [#1991](https://github.com/MithrilJS/mithril.js/issues/1991), [#2003](https://github.com/MithrilJS/mithril.js/issues/2003), [#2021](https://github.com/MithrilJS/mithril.js/pull/2021) + +--- + +### v1.1.6 + +- core: render() function can no longer prevent from changing `document.activeElement` in lifecycle hooks ([#1988](https://github.com/MithrilJS/mithril.js/pull/1988), [@purplecode](https://github.com/purplecode)) +- core: don't call `onremove` on the children of components that return null from the view [#1921](https://github.com/MithrilJS/mithril.js/issues/1921) [@octavore](https://github.com/octavore) ([#1922](https://github.com/MithrilJS/mithril.js/pull/1922)) +- hypertext: correct handling of shared attributes object passed to `m()`. Will copy attributes when it's necessary [#1941](https://github.com/MithrilJS/mithril.js/issues/1941) [@s-ilya](https://github.com/s-ilya) ([#1942](https://github.com/MithrilJS/mithril.js/pull/1942)) + +#### Ospec improvements + +- ospec v1.4.0 + - Added support for async functions and promises in tests ([#1928](https://github.com/MithrilJS/mithril.js/pull/1928), [@StephanHoyer](https://github.com/StephanHoyer)) + - Error handling for async tests with `done` callbacks supports error as first argument ([#1928](https://github.com/MithrilJS/mithril.js/pull/1928)) + - Error messages which include newline characters do not swallow the stack trace [#1495](https://github.com/MithrilJS/mithril.js/issues/1495) ([#1984](https://github.com/MithrilJS/mithril.js/pull/1984), [@RodericDay](https://github.com/RodericDay)) +- ospec v2.0.0 (to be released) + - Added support for custom reporters ([#2009](https://github.com/MithrilJS/mithril.js/pull/2020)) + - Make Ospec more [Flems](https://flems.io)-friendly ([#2034](https://github.com/MithrilJS/mithril.js/pull/2034)) + - Works either as a global or in CommonJS environments + - the o.run() report is always printed asynchronously (it could be synchronous before if none of the tests were async). + - Properly point to the assertion location of async errors [#2036](https://github.com/MithrilJS/mithril.js/issues/2036) + - expose the default reporter as `o.report(results)` + - Don't try to access the stack traces in IE9 + +--- + +### v1.1.5 + +- API: If a user sets the Content-Type header within a request's options, that value will be the entire header value rather than being appended to the default value ([#1924](https://github.com/MithrilJS/mithril.js/pull/1924)) --- @@ -46,9 +79,7 @@ #### Bug fixes: -- core: don't call `onremove` on the children of components that return null from the view [#1921](https://github.com/MithrilJS/mithril.js/issues/1921) [octavore](https://github.com/octavore) ([#1922](https://github.com/MithrilJS/mithril.js/pull/1922)) -- hypertext: correct handling of shared attributes object passed to `m()`. Will copy attributes when it's necessary [#1941](https://github.com/MithrilJS/mithril.js/issues/1941) [s-ilya](https://github.com/s-ilya) ([#1942](https://github.com/MithrilJS/mithril.js/pull/1942)) -- Fix IE bug where active element is null causing render function to throw error. ([1943](https://github.com/MithrilJS/mithril.js/pull/1943)) +- Fix IE bug where active element is null causing render function to throw error ([#1943](https://github.com/MithrilJS/mithril.js/pull/1943), [@JacksonJN](https://github.com/JacksonJN)) #### Ospec improvements: diff --git a/docs/code-of-conduct.md b/docs/code-of-conduct.md index 50ebe61d..a8875af5 100644 --- a/docs/code-of-conduct.md +++ b/docs/code-of-conduct.md @@ -55,7 +55,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at [github@patcavit.com](mailto:github@patcavit.com?subject=Mithril Code of Conduct). All +reported by contacting the project team at [github@patcavit.com](mailto:github@patcavit.com?subject=Mithril%20Code%20of%20Conduct). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. diff --git a/docs/installation.md b/docs/installation.md index c4604ad5..57efabe6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -17,28 +17,48 @@ If you're new to Javascript or just want a very simple setup to get your feet we #### Quick start with Webpack + +1. Initialize the directory as an npm package ```bash -# 1) install -npm install mithril --save - -npm install webpack --save - -# 2) add this line into the scripts section in package.json -# "scripts": { -# "start": "webpack src/index.js bin/app.js --watch" -# } - -# 3) create an `src/index.js` file - -# 4) create an `index.html` file containing `` - -# 5) run bundler -npm start - -# 6) open `index.html` in the (default) browser -open index.html +$> npm init --yes ``` +2. install required tools +```bash +$> npm install mithril --save +$> npm install webpack --save +``` + +3. Add a "start" entry to the scripts section in `package.json` +```js +{ + // ... + "scripts": { + "start": "webpack src/index.js bin/app.js --watch" + } +} +``` + +3. Create `src/index.js` +```js +import m from "mithril"; + +m.render(document.body, "hello world"); +``` + +4. create `index.html` +```html + + + +``` + +5. run bundler +```bash +$> npm start +``` +6. open `index.html` in your (default) browser + #### Step by step For production-level projects, the recommended way of installing Mithril is to use NPM. diff --git a/docs/integrating-libs.md b/docs/integrating-libs.md new file mode 100644 index 00000000..42b72bee --- /dev/null +++ b/docs/integrating-libs.md @@ -0,0 +1,57 @@ +# Integrating with Other Libraries +Integration with third party libraries or vanilla javascript code can be achieved via [lifecycle methods](lifecycle-methods.md). + +- [Usage](#usage) + +### Usage +```javascript +var FullCalendar = { + + oncreate: function (vnode) { + console.log('FullCalendar::oncreate') + $(vnode.dom).fullCalendar({ + // put your initial options and callbacks here + }) + + Object.assign(vnode.attrs.parentState, {fullCalendarEl: vnode.dom}) + }, + + // Consider that the lib will modify this parent element in the DOM (e.g. add dependent class attribute and values). + // As long as you return the same view results here, mithril will not + // overwrite the actual DOM because it's always comparing old and new VDOM + // before applying DOM updates. + view: function (vnode) { + return m('div') + }, + + onbeforeremove: function (vnode) { + // Run any destroy / cleanup methods here. + //E.g. $(vnode.state.fullCalendarEl).fullCalendar('destroy') + } +} + +m.mount(document.body, { + view: function (vnode) { + return [ + m('h1', 'Calendar'), + m(FullCalendar, {parentState: vnode.state}), + m('button', {onclick: prev}, 'Mithril Button -'), + m('button', {onclick: next}, 'Mithril Button +') + + ] + + function next() { + $(vnode.state.fullCalendarEl).fullCalendar('next') + } + + function prev() { + $(vnode.state.fullCalendarEl).fullCalendar('prev') + } + + } + +}) + +``` + +Running example [flems: FullCalendar](https://flems.io/#0=N4IgZglgNgpgziAXAbVAOwIYFsZJAOgAsAXLKEAGhAGMB7NYmBvAHigjQGsACAJxigBeADog4xAJ6w4hGDGKjuhfmBEgSxAA5xEAel3UAJmgBWcfNSi0ArobBQM-C7Sy6MJjAA9d7AEZxdMGsoKGoMWDRDR10AZnwAdnwABkDg0PCmKN58LA4LODhRAD4QAF8KdGxcRAIzShp6RmYagDdHbgAxNIBhDMj2wW5gYTQR4WJ6an4MRkRuILRqYgh6bgAKFrRaQxgASiGxhWI6NDhaWHwrAHM1gHIukN6oft5EREnpxlvdw-GAEg2Wx2+EMLl2+CCjz6WTWw1GR3G+m4mmsxG4EhsvG4HAgy3C3FommW9Dg3AwkW4YRCvgw1E4pNk-F+xFKP1G8PGAHlfCYYEt8BgChArmhAdsYALiMReOZNI4mMQAMrEGYwChDSFQJ6ZRwAUSgc024pBLlZh3KY3hLQgMAA7nMFksVmh1kadvs4eNxvxiNZeC6sHdDBAWt9zRRLeN6L4YGBaPx+FhaC0YA7rItiS6xe6DhziEiAErpsloCTcHbiXi0Mu6SmwcnWTTcHDEQjbBkwJzM-QAt0S8SqiE9aF6qDgzXal5B+DS6th+GlEaL9lYHI2BhrUHUaw4Bj4XzbCTqz3Ea12tMZ52uoF7XNe6XyP0u5DM8aB26EACMt3Vt0nWW+CM8zfNYHi1EdeGPOV+AYZVVUNG98AHRhWSA+8QNuXxUQmNAfzvBEjkmdg6TmTR+BaV8WV-ABZXFlGgbgACFsNWABaQDKPfLCpXoPCT3QnDLAgEjuDQGBPAUYCqO4W5aNbXgGOYniXQAannZkAF1IyOR1M1E8TiDWD1KN7RDkIlCcIP1cdhwiGFbjEiT1KOZdmV0q8yJgFojPw+9TONcyhyhOzRxs4KdV4O5PNDNl71chdLVZMoKhATAcDwfIECoE4mmIPAyg0qh2C4BAUEqdKalyeToHqP1yBqDRtD0XR000TgrmcVwqvoqAAAFP3wAaAFZdG6hSoHwOoqEkTRqhAOpynKuak13PKqDqvBGp0fRWvazrRpcBVeoAJkGgBOfBjoO1bJqykAZrmhaUrSx6AEdrE7CRat4er1ClJqdrQNqOroVwTHez7eriU7P10YNxF0cGPt4CRbvqB68Cepa8E1KkIu+36tua3aQZcVIQjxl4oYSZI4YgBHcYgtHpokWbMYQUoNNKIA) \ No newline at end of file diff --git a/docs/learning-mithril.md b/docs/learning-mithril.md new file mode 100644 index 00000000..fb599df8 --- /dev/null +++ b/docs/learning-mithril.md @@ -0,0 +1,5 @@ +# Learning Mithril + +Links to Mithril learning content: + +- [Mithril 0-60](https://scrimba.com/playlist/playlist-34) diff --git a/docs/nav-guides.md b/docs/nav-guides.md index 872ce86b..4a5be9df 100644 --- a/docs/nav-guides.md +++ b/docs/nav-guides.md @@ -9,6 +9,7 @@ - [Animation](animation.md) - [Testing](testing.md) - [Examples](examples.md) + - [Learning Mithril](learning-mithril.md) - Key concepts - [Vnodes](vnodes.md) - [Components](components.md) diff --git a/docs/releasing.md b/docs/releasing.md index 37bec28d..ce4a871d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -2,6 +2,10 @@ **Note** These steps all assume that `MithrilJS/mithril.js` is a git remote named `mithriljs`, adjust accordingly if that doesn't match your setup. +- [Releasing a new Mithril version](#releasing-a-new-mithril-version) +- [Updating mithril.js.org](#updating-mithriljsorg) +- [Releasing a new ospec version](#releasing-a-new-ospec-version) + ## Releasing a new Mithril version ### Prepare the release @@ -112,3 +116,61 @@ $ git push mithriljs ``` After the Travis build completes the updated docs should appear on https://mithril.js.org in a few minutes. + +## Releasing a new ospec version + +1. Ensure your local branch is up to date + +```bash +$ git co next +$ git pull --rebase mithriljs next +``` + +2. Determine patch level of the change +3. Update `version` field in `ospec/package.json` to match new version being prepared for release +4. Commit changes to `next` + +``` +$ git add . +$ git commit -m "chore(ospec): ospec@" + +# Push to your branch +$ git push + +# Push to MithrilJS/mithril.js +$ git push mithriljs next +``` + +### Merge from `next` to `master` + +5. Switch to `master` and make sure it's up to date + +```bash +$ git co master +$ git pull --rebase mithriljs master +``` + +6. merge `next` on top of it + +```bash +$ git checkout next -- ./ospec +$ git add . +$ git commit -m "chore(ospec): ospec@" +``` + +7. Ensure the tests are passing! + +### Publish the release + +8. Push the changes to `MithrilJS/mithril.js` + +```bash +$ git push mithriljs master +``` + +9. Publish the changes to npm **from the `/ospec` folder**. That bit is important to ensure you don't accidentally ship a new Mithril release! + +```bash +$ cd ./ospec +$ npm publish +``` diff --git a/docs/request.md b/docs/request.md index fe962e50..44bb954b 100644 --- a/docs/request.md +++ b/docs/request.md @@ -49,12 +49,13 @@ Argument | Type | Required | Descr `options.user` | `String` | No | A username for HTTP authorization. Defaults to `undefined`. `options.password` | `String` | No | A password for HTTP authorization. Defaults to `undefined`. This option is provided for `XMLHttpRequest` compatibility, but you should avoid using it because it sends the password in plain text over the network. `options.withCredentials` | `Boolean` | No | Whether to send cookies to 3rd party domains. Defaults to `false` +`options.timeout` | `Number` | No | The amount of milliseconds a request can take before automatically being [terminated](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/timeout). Defaults to `undefined`. `options.config` | `xhr = Function(xhr)` | No | Exposes the underlying XMLHttpRequest object for low-level configuration. Defaults to the [identity function](https://en.wikipedia.org/wiki/Identity_function). `options.headers` | `Object` | No | Headers to append to the request before sending it (applied right before `options.config`). `options.type` | `any = Function(any)` | No | A constructor to be applied to each object in the response. Defaults to the [identity function](https://en.wikipedia.org/wiki/Identity_function). `options.serialize` | `string = Function(any)` | No | A serialization method to be applied to `data`. Defaults to `JSON.stringify`, or if `options.data` is an instance of [`FormData`](https://developer.mozilla.org/en/docs/Web/API/FormData), defaults to the [identity function](https://en.wikipedia.org/wiki/Identity_function) (i.e. `function(value) {return value}`). `options.deserialize` | `any = Function(string)` | No | A deserialization method to be applied to the `xhr.responseText`. Defaults to a small wrapper around `JSON.parse` that returns `null` for empty responses. If `extract` is defined, `deserialize` will be skipped. -`options.extract` | `any = Function(xhr, options)` | No | A hook to specify how the XMLHttpRequest response should be read. Useful for processing response data, reading headers and cookies. By default this is a function that returns `xhr.responseText`, which is in turn passed to `deserialize`. If a custom `extract` callback is provided, the `xhr` parameter is the XMLHttpRequest instance used for the request, and `options` is the object that was passed to the `m.request` call. Additionally, `deserialize` will be skipped and the value returned from the extract callback will not automatically be parsed as JSON. +`options.extract` | `any = Function(xhr, options)` | No | A hook to specify how the XMLHttpRequest response should be read. Useful for processing response data, reading headers and cookies. By default this is a function that returns `xhr.responseText`, which is in turn passed to `deserialize`. If a custom `extract` callback is provided, the `xhr` parameter is the XMLHttpRequest instance used for the request, and `options` is the object that was passed to the `m.request` call. Additionally, `deserialize` will be skipped and the value returned from the extract callback will be left as-is when the promise resolves. Furthermore, when an extract callback is provided, exceptions are *not* thrown when the server response status code indicates an error. `options.useBody` | `Boolean` | No | Force the use of the HTTP body section for `data` in `GET` requests when set to `true`, or the use of querystring for other HTTP methods when set to `false`. Defaults to `false` for `GET` requests and `true` for other methods. `options.background` | `Boolean` | No | If `false`, redraws mounted components upon completion of the request. If `true`, it does not. Defaults to `false`. **returns** | `Promise` | | A promise that resolves to the response data, after it has been piped through the `extract`, `deserialize` and `type` methods @@ -81,6 +82,8 @@ A call to `m.request` returns a [promise](promise.md) and triggers a redraw upon By default, `m.request` assumes the response is in JSON format and parses it into a Javascript object (or array). +If the HTTP response status code indicates an error, the returned Promise will be rejected. Supplying an extract callback will prevent the promise rejection. + --- ### Typical usage @@ -426,7 +429,7 @@ m.request({ ### Retrieving response details -By default Mithril attempts to parse a response as JSON and returns `xhr.responseText`. It may be useful to inspect a server response in more detail, this can be accomplished by passing a custom `options.extract` function: +By default Mithril attempts to parse `xhr.responseText` as JSON and returns the parsed object. It may be useful to inspect a server response in more detail and process it manually. This can be accomplished by passing a custom `options.extract` function: ```javascript m.request({ diff --git a/docs/route.md b/docs/route.md index 73b0e395..712c6845 100644 --- a/docs/route.md +++ b/docs/route.md @@ -104,7 +104,7 @@ Argument | Type | Required | Description This function can be used as the `oncreate` (and `onupdate`) hook in a `m("a")` vnode: ```JS -m("a[href=/]", {oncreate: m.route.link})`. +m("a[href=/]", {oncreate: m.route.link}) ``` Using `m.route.link` as a `oncreate` hook causes the link to behave as a router link (i.e. it navigates to the route specified in `href`, instead of navigating away from the current page to the URL specified in `href`. @@ -112,15 +112,21 @@ Using `m.route.link` as a `oncreate` hook causes the link to behave as a router If the `href` attribute is not static, the `onupdate` hook must also be set: ```JS -m("a", {href: someVariable, oncreate: m.route.link, onupdate: m.route.link})` +m("a", {href: someVariable, oncreate: m.route.link, onupdate: m.route.link}) ``` -`m.route.link(vnode)` +`m.route.link` can also set the `options` passed to `m.route.set` when the link is clicked by calling the function in the lifecycle methods: -Argument | Type | Required | Description ------------------ | ----------- | -------- | --- -`vnode` | `Vnode` | Yes | This method is meant to be used as or in conjunction with an `` [vnode](vnodes.md)'s [`oncreate` and `onupdate` hooks](lifecycle-methods.md) -**returns** | | | Returns `undefined` +```JS +m("a[href=/]", {oncreate: m.route.link({replace: true})}) +``` + +`m.route.link(args)` + +Argument | Type | Required | Description +----------------- | ---------------| -------- | --- +`args` | `Vnode|Object` | Yes | This method is meant to be used as or in conjunction with an `` [vnode](vnodes.md)'s [`oncreate` and `onupdate` hooks](lifecycle-methods.md) +**returns** | `function` | | Returns the onclick handler function for the component ##### m.route.param @@ -588,7 +594,7 @@ m.route(document.body, "/secret", { #### Preloading data -Typically, a component can load data upon initialization. Loading data this way renders the component twice (once upon routing, and once after the request completes). +Typically, a component can load data upon initialization. Loading data this way renders the component twice. The first render pass occurs upon routing, and the second fires after the request completes. Take care to note that `loadUsers()` returns a Promise, but any Promise returned by `oninit` is currently ignored. The second render pass comes from the [`background` option for `m.request`](request.md). ```javascript var state = { diff --git a/docs/simple-application.md b/docs/simple-application.md index cd943cc8..9ca3a698 100644 --- a/docs/simple-application.md +++ b/docs/simple-application.md @@ -2,6 +2,8 @@ Let's develop a simple application that covers some of the major aspects of Single Page Applications +An interactive running example can be seen here [flems: Simple Application](https://flems.io/#0=N4IgzgpgNhDGAuEAmIBcICGAHLA6AVmCADQgBmAljEagNqgB2GAthGiAKqQBOAsgPZJoBIqVj8GiSewBuGbgAIuERQF4FwADoMFuhVAph4qBbQC6xbXv38MSADKHjCsgFcGCChIAUASg1W1nrcEPCu3DrMuCEAjq4QRt5aOkGprPAAFoImmiAA4gCiACq5limp1uFQOSAZ8PBYYKgA9M0hzAC0IUYd2BS4GSr8ANau2HjizM19za48YKWBFXoA7hSZAMIhQpIUGFBNCvDc8WXLAL6+S0G4mRAM3m4e8F4P3a5Q8P7Jy9bK3LgDEYFOp3p9cEgMPAMNdrJdrucytdYOEQpITMBEdcoLYkCYnp4fBQkN9YcFQuFItEIHEEvAkmS0qEsniFLlCiUSIyglUanUGk1Wu0unTelh+oNuCMxjhcJNpuLZvNmrkFABqBTEs6-XRrTbbe4vfaHY6nbnw8o3O4PAkvHxgr4BS3Lf5y1GGkEKB3mq6WrEMa5gDAyCD49yEh6k53ksIRBRRWLxRI-HXx5nZNkgAAKHE52p1vMz-MaLTaEE63XgYolQ1G4zl-CmMzmKjAKpA6qUPDd3DR8FwWu51kh0JMrpRvcN+d+eoyW2Qhr2BxMpog07hvrh2nOIERjBYbHQ-0cRhEJBA4kkhtk8i7KhP8E9Kd0EgoDHWY+7OLsD+nMgoEArGGzyvH4TrLCEsaRN4uS4C23AdEC8ClHeAJIbgzDYI84Z2g88FRqmXoUnGzAwZgcE8IhTgdOs5YocAGQhGQNTNMg6ztp28EDkgxAKBIsAhFCobxtE-CuIggJvsMiIKFxlDcEYAByB6dqqqoalxUAYEpB6bhUlx6bo5zbruxD7qw7D-AAYvw3BRIQ56XlI8A3oo1m2cwT7XK+77OLaoEyAwggQN8rrfkg3iBcFuBQscYDcb4-rWP+gHARGYHPkEkGUvGZFkB59FDhUEhgK4ABGzAfi4OGgSF4GERUEC4FgIQhpIAAiEBkBgHz0oZDV-N2QYhn4RWpMZ0bjbxtBjbluRaWVwgLdAKG5FZFAKY+TCsLkvjrhUpG5G+WDiQODAnfAtDwAAnlgECqIgAAe8BmLQWBabAEBZFAQjcKo62bQo20QGYhWTb8PkXSYUSzgAgvU3BkXIUDxCh-k+Mj8Shd2E59rg8k6awnqYxAlz7TqJOfioPZ4wT8DKTt4MbuTQSHSAy1QICGCLVAq0gPY2lbQeu0s9YbPHadEuXe9GCfd9v2qALwLA6DJD1QNkPidDuBwwjSP7Kjavow8JPY9TuOGlzhMQMTBuk3ts3JXbVMAhbkhW-TwtM3oZOzWzZXifAEi4AH9QSFdt33aVFXrKrvG5AAysGEAi9yZj9RNO57iAwPsAL11if2DliBIzmuQo+eF15lopUB1UgRjQVCARFTZSRZGYW+XMF+JKEzd7uhs0wMgYfcrh947ehszCauZQNjFdSYADkzRIUvosQx4gmINrUriU1BgMMMk9GfHnDzLts3pxvg9kZAEYoVFQhyhkVBIGi-XWOnCImdnufoPWYuF5S7XnQAmQuTUWpdQoI9bwS8ADES9fTgP3t4JA-AUSsHdmVQQ10z6rycGDawuQCFGFyBibkaJfppVwhlWabdoKV3ErxUix4nC+E-j7BE04SFsXgM0VAxJyHqyyvcah9d0pPzqnPVuxFGEYB7vAFh3h3J2V4lImKCMwAcPNNw7cvhTLmUPCAOUYBRDAKvNIdAOCkB4LOhdYgIdA4SA0PldEQU7L7AUAARgAGxYEegoAAaioSETAADcmFuAAHM3wmAAEwAAYAkTW0N3KuwAomxIYKgbxyTAk9SDpEjAj0OhrCQJkXJiTqkBPCRNUeDBXAaCyXExJCg2kAGZ8l1O0Gk+CVFgTACQh0Iw10YCoCCgwCAxSYmtPaT47pWA7BIDfNE1AiSekMAoioAZVZaKeWAGVWWwxol7wYHieB3UrkYHCTg7g1DvEBIUGAfgBgkAKHgUgL54TxA4m4KgeBHSgXhJWWAGW11UBlRxLAYYMzsnrPmY8x64SllfNWagAAHE87xABWWpT0qxCHENwKErwJkSGmfU-pwz9moCyCGRQwACUdCJbZUlEhUDuF+ofSlvStkcw0KC8FkLoWwpaTktpbS8XIvqVLDQdyHlPJeW8j5XykC3Nsr9LodgKBzFQB02pODSlgAoAAL3RQqnZRqQWGGFVCjBYr5DwslQs2pqKVkMDWXk7F0rwnlMqXkxJABSTZTiw46EOcc05YlzkAogPGjV9yVC5KVa84kqrvmWoQiSlZeqDXIt+bZAFQKOk2rBVpCFb4eUdHtTCuFcy2neuRe69FTafG+uZaykluFyVTNDaHIOOT6UqHlVGs5FyIAYsnZOupu4LDsykjQegOcDzsEqpkbgVBzxVHYMWQUsxzonIbFMddjEqAAAFvG4Cvb45op7N2cyATdO67AHLnDMOcIAA) + First let's create an entry point for the application. Create a file `index.html`: ```markup diff --git a/docs/style.css b/docs/style.css index bf0e6874..3da4e572 100644 --- a/docs/style.css +++ b/docs/style.css @@ -1,4 +1,4 @@ -body {-webkit-text-size-adjust: 100%;} +body {background:white;-webkit-text-size-adjust: 100%;} body,table,h5 {font:normal 16px 'Open Sans';} header,main {margin:auto;max-width:1000px;} header section {position:absolute;width:250px;} @@ -57,7 +57,7 @@ h1 + ul strong + ul {border-left:3px solid #1e5799;} .hamburger:hover {text-decoration:none;} main section {margin:0;} header section {margin:0 0 20px;position:static;width:auto;} - h1 + ul {background:#eee;border:1px solid #ccc;box-sizing:border-box;display:none;height:100%;margin:0;overflow:auto;padding:20px;position:fixed;right:0;top:0;width:100%;} + h1 + ul {background:#eee;border:1px solid #ccc;box-sizing:border-box;display:none;height:100%;margin:0;overflow:auto;padding:20px;position:fixed;right:0;top:0;width:100%;z-index:1} h1 + ul + hr {display:block;} .navigating h1 + ul {display:block;} .navigating {overflow:hidden;} diff --git a/docs/support.md b/docs/support.md new file mode 100644 index 00000000..ae466e16 --- /dev/null +++ b/docs/support.md @@ -0,0 +1,3 @@ +## Getting Help + +Mithril has an active & welcoming community on [Gitter](https://gitter.im/mithriljs/mithril.js), or feel free to ask questions on [Stack Overflow](https://stackoverflow.com/questions/tagged/mithril.js) using the `mithril.js` tag. diff --git a/docs/testing.md b/docs/testing.md index ba850a5b..ead11517 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -74,7 +74,9 @@ var m = require("mithril") module.exports = { view: function() { - return m("div", "Hello world") + return m("div", + m("p", "Hello World") + ) } } ``` @@ -90,7 +92,7 @@ o.spec("MyComponent", function() { o(vnode.tag).equals("div") o(vnode.children.length).equals(1) - o(vnode.children[0].tag).equals("#") + o(vnode.children[0].tag).equals("p") o(vnode.children[0].children).equals("Hello world") }) }) diff --git a/docs/vnodes.md b/docs/vnodes.md index cff0ebe4..783f432f 100644 --- a/docs/vnodes.md +++ b/docs/vnodes.md @@ -74,7 +74,6 @@ Property | Type | Description `dom` | `Element?` | Points to the element that corresponds to the vnode. This property is `undefined` in the `oninit` lifecycle method. In fragments and trusted HTML vnodes, `dom` points to the first element in the range. `domSize` | `Number?` | This is only set in fragment and trusted HTML vnodes, and it's `undefined` in all other vnode types. It defines the number of DOM elements that the vnode represents (starting from the element referenced by the `dom` property). `state` | `Object?` | An object that is persisted between redraws. It is provided by the core engine when needed. In POJO component vnodes, the `state` inherits prototypically from the component object/class. In class component vnodes it is an instance of the class. In closure components it is the object returned by the closure. -`_state` | `Object?` | For components, a reference to the original `vnode.state` object, used to lookup the `view` and hooks. This property is only used internally by Mithril, do not use or modify it. `events` | `Object?` | An object that is persisted between redraws and that stores event handlers so that they can be removed using the DOM API. The `events` property is `undefined` if there are no event handlers defined. This property is only used internally by Mithril, do not use or modify it. `instance` | `Object?` | For components, a storage location for the value returned by the `view`. This property is only used internally by Mithril, do not use or modify it. `skip` | `Boolean` | This property is only used internally by Mithril when diffing keyed lists, do not use or modify it. @@ -89,7 +88,7 @@ The `tag` property of a vnode determines its type. There are five vnode types: Vnode type | Example | Description ------------ | ------------------------------ | --- Element | `{tag: "div"}` | Represents a DOM element. -Fragment | `{tag: "[", children: []}` | Represents a list of DOM elements whose parent DOM element may also contain other elements that are not in the fragment. When using the [`m()`](hyperscript.md) helper function, fragment vnodes can only be created by nesting arrays into the `children` parameter of `m()`. `m("[")` does not create a valid vnode. +Fragment | `{tag: "[", children: []}` | Represents a list of DOM elements whose parent DOM element may also contain other elements that are not in the fragment. When using the [`m()`](hyperscript.md) helper function, fragment vnodes can only be created by nesting arrays into the `children` parameter of `m()`. `m("[")` does not create a valid vnode. Text | `{tag: "#", children: ""}` | Represents a DOM text node. Trusted HTML | `{tag: "<", children: "
"}` | Represents a list of DOM elements from an HTML string. Component | `{tag: ExampleComponent}` | If `tag` is a Javascript object with a `view` method, the vnode represents the DOM generated by rendering the component. diff --git a/mithril.js b/mithril.js index a953152b..8546f584 100644 --- a/mithril.js +++ b/mithril.js @@ -1,7 +1,7 @@ ;(function() { "use strict" function Vnode(tag, key, attrs0, children, text, dom) { - return {tag: tag, key: key, attrs: attrs0, children: children, text: text, dom: dom, domSize: undefined, state: undefined, _state: undefined, events: undefined, instance: undefined, skip: false} + return {tag: tag, key: key, attrs: attrs0, children: children, text: text, dom: dom, domSize: undefined, state: undefined, events: undefined, instance: undefined, skip: false} } Vnode.normalize = function(node) { if (Array.isArray(node)) return Vnode("[", undefined, undefined, Vnode.normalizeChildren(node), undefined, undefined) @@ -294,6 +294,7 @@ var _8 = function($window, Promise) { xhr.setRequestHeader("Accept", "application/json, text/*") } if (args.withCredentials) xhr.withCredentials = args.withCredentials + if (args.timeout) xhr.timeout = args.timeout for (var key in args.headers) if ({}.hasOwnProperty.call(args.headers, key)) { xhr.setRequestHeader(key, args.headers[key]) } @@ -304,7 +305,7 @@ var _8 = function($window, Promise) { if (xhr.readyState === 4) { try { var response = (args.extract !== extract) ? args.extract(xhr, args) : args.deserialize(args.extract(xhr, args)) - if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304 || FILE_PROTOCOL_REGEX.test(args.url)) { + if (args.extract !== extract || (xhr.status >= 200 && xhr.status < 300) || xhr.status === 304 || FILE_PROTOCOL_REGEX.test(args.url)) { resolve(cast(args.type, response)) } else { @@ -398,6 +399,22 @@ var coreRenderer = function($window) { function getNameSpace(vnode) { return vnode.attrs && vnode.attrs.xmlns || nameSpace[vnode.tag] } + //sanity check to discourage people from doing `vnode.state = ...` + function checkState(vnode, original) { + if (vnode.state !== original) throw new Error("`vnode.state` must not be modified") + } + //Note: the hook is passed as the `this` argument to allow proxying the + //arguments without requiring a full array allocation to do so. It also + //takes advantage of the fact the current `vnode` is the first argument in + //all lifecycle methods. + function callHook(vnode) { + var original = vnode.state + try { + return this.apply(original, arguments) + } finally { + checkState(vnode, original) + } + } //create function createNodes(parent, vnodes, start, end, hooks, nextSibling, ns) { for (var i = start; i < end; i++) { @@ -495,10 +512,9 @@ var coreRenderer = function($window) { sentinel.$$reentrantLock$$ = true vnode.state = (vnode.tag.prototype != null && typeof vnode.tag.prototype.view === "function") ? new vnode.tag(vnode) : vnode.tag(vnode) } - vnode._state = vnode.state if (vnode.attrs != null) initLifecycle(vnode.attrs, vnode, hooks) - initLifecycle(vnode._state, vnode, hooks) - vnode.instance = Vnode.normalize(vnode._state.view.call(vnode.state, vnode)) + initLifecycle(vnode.state, vnode, hooks) + vnode.instance = Vnode.normalize(callHook.call(vnode.state.view, vnode)) if (vnode.instance === vnode) throw Error("A view cannot return the vnode it received as argument") sentinel.$$reentrantLock$$ = null } @@ -517,85 +533,204 @@ var coreRenderer = function($window) { } } //update - function updateNodes(parent, old, vnodes, recycling, hooks, nextSibling, ns) { - if (old === vnodes || old == null && vnodes == null) return + /** + * @param {Element|Fragment} parent - the parent element + * @param {Vnode[] | null} old - the list of vnodes of the last0 `render()` call for + * this part of the tree + * @param {Vnode[] | null} vnodes - as above, but for the current `render()` call. + * @param {boolean} recyclingParent - was the parent vnode or one of its ancestor + * fetched from the recycling pool? + * @param {Function[]} hooks - an accumulator of post-render hooks (oncreate/onupdate) + * @param {Element | null} nextSibling - the next0 DOM node if we're dealing with a + * fragment that is not the last0 item in its + * parent + * @param {'svg' | 'math' | String | null} ns) - the current XML namespace, if any + * @returns void + */ + // This function diffs and patches lists of vnodes, both keyed and unkeyed. + // + // We will: + // + // 1. describe its general structure + // 2. focus on the diff algorithm optimizations + // 3. describe how the recycling pool meshes into this + // 4. discuss DOM node operations. + // ## Overview: + // + // The updateNodes() function: + // - deals with trivial cases + // - determines whether the lists are keyed or unkeyed + // (Currently we look for the first pair of non-null nodes and deem the lists unkeyed + // if both nodes are unkeyed. TODO (v2) We may later take advantage of the fact that + // mixed diff is not supported and settle on the keyedness of the first vnode we find) + // - diffs them and patches the DOM if needed (that's the brunt of the code) + // - manages the leftovers: after diffing, are there: + // - old nodes left to remove? + // - new nodes to insert? + // - nodes left in the recycling pool? + // deal with them! + // + // The lists are only iterated over once, with an exception for the nodes in `old` that + // are visited in the fourth part of the diff and in the `removeNodes` loop. + // ## Diffing + // + // There's first a simple diff for unkeyed lists of equal length that eschews the pool. + // + // It is followed by a small section that activates the recycling pool if present, we'll + // ignore it for now. + // + // Then comes the main diff algorithm that is split in four parts (simplifying a bit). + // + // The first part goes through both lists top-down as long as the nodes at each level have + // the same key2. This is always true for unkeyed lists that are entirely processed by this + // step. + // + // The second part deals with lists reversals, and traverses one list top-down and the other + // bottom-up (as long as the keys match1). + // + // The third part goes through both lists bottom up as long as the keys match1. + // + // The first and third sections allow us to deal efficiently with situations where one or + // more contiguous nodes were either inserted into, removed from or re-ordered in an otherwise + // sorted list. They may reduce the number of nodes to be processed in the fourth section. + // + // The fourth section does keyed diff for the situations not covered by the other three. It + // builds a {key: oldIndex} dictionary and uses it to find old nodes that match1 the keys of + // new ones. + // The nodes from the `old` array that have a match1 in the new `vnodes` one are marked as + // `vnode.skip: true`. + // + // If there are still nodes in the new `vnodes` array that haven't been matched to old ones, + // they are created. + // The range of old nodes that wasn't covered by the first three sections is passed to + // `removeNodes()`. Those nodes are removed unless marked as `.skip: true`. + // + // Then some pool business happens. + // + // It should be noted that the description of the four sections above is not perfect, because those + // parts are actually implemented as only two loops, one for the first two parts, and one for + // the other two. I'm1 not sure it wins us anything except maybe a few bytes of file size. + // ## The pool + // + // `old.pool` is an optional array that holds the vnodes that have been previously removed + // from the DOM at this level (provided they met the pool eligibility criteria). + // + // If the `old`, `old.pool` and `vnodes` meet some criteria described in `isRecyclable`, the + // elements of the pool are appended to the `old` array, which enables the reconciler to find + // them. + // + // While this is optimal for unkeyed diff and map-based keyed diff (the fourth diff part), + // that strategy clashes with the second and third parts of the main diff algo, because + // the end of the old list is now filled with the nodes of the pool. + // + // To determine if a vnode was brought back from the pool, we look at its position in the + // `old` array (see the various `oFromPool` definitions). That information is important + // in three circumstances: + // - If the old and the new vnodes are the same object (`===`), diff is not performed unless + // the old node comes from the pool (since it must be recycled/re-created). + // - The value of `oFromPool` is passed as the `recycling` parameter of `updateNode()` (whether + // the parent is being recycled is also factred in here) + // - It is used in the DOM node insertion logic (see below) + // + // At the very end of `updateNodes()`, the nodes in the pool that haven't been picked back + // are put in the new pool for the next0 render phase. + // + // The pool eligibility and `isRecyclable()` criteria are to be updated as part of #1675. + // ## DOM node operations + // + // In most cases `updateNode()` and `createNode()` perform the DOM operations. However, + // this is not the case if the node moved (second and fourth part of the diff algo), or + // if the node was brough back from the pool and both the old and new nodes have the same + // `.tag` value (when the `.tag` differ, `updateNode()` does the insertion). + // + // The fourth part of the diff currently inserts nodes unconditionally, leading to issues + // like #1791 and #1999. We need to be smarter about those situations where adjascent old + // nodes remain together in the new list in a way that isn't covered by parts one and + // three of the diff algo. + function updateNodes(parent, old, vnodes, recyclingParent, hooks, nextSibling, ns) { + if (old === vnodes && !recyclingParent || old == null && vnodes == null) return else if (old == null) createNodes(parent, vnodes, 0, vnodes.length, hooks, nextSibling, ns) - else if (vnodes == null) removeNodes(old, 0, old.length, vnodes) + else if (vnodes == null) removeNodes(old, 0, old.length, vnodes, recyclingParent) else { - 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) createNode(parent, 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), recycling, ns) - } - return + var start = 0, commonLength = Math.min(old.length, vnodes.length), originalOldLength = old.length, hasPool = false, isUnkeyed = false + for(; start < commonLength; start++) { + if (old[start] != null && vnodes[start] != null) { + if (old[start].key == null && vnodes[start].key == null) isUnkeyed = true + break } } - recycling = recycling || isRecyclable(old, vnodes) - if (recycling) { - var pool = old.pool + if (isUnkeyed && originalOldLength === vnodes.length) { + for (start = 0; start < originalOldLength; start++) { + if (old[start] === vnodes[start] && !recyclingParent || old[start] == null && vnodes[start] == null) continue + else if (old[start] == null) createNode(parent, vnodes[start], hooks, ns, getNextSibling(old, start + 1, originalOldLength, nextSibling)) + else if (vnodes[start] == null) removeNodes(old, start, start + 1, vnodes, recyclingParent) + else updateNode(parent, old[start], vnodes[start], hooks, getNextSibling(old, start + 1, originalOldLength, nextSibling), recyclingParent, ns) + } + return + } + if (isRecyclable(old, vnodes)) { + hasPool = true old = old.concat(old.pool) } - var oldStart = 0, start = 0, oldEnd = old.length - 1, end = vnodes.length - 1, map + var oldStart = start = 0, oldEnd = old.length - 1, end = vnodes.length - 1, map, o, v, oFromPool 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) { - var shouldRecycle = (pool != null && oldStart >= old.length - pool.length) || ((pool == null) && recycling) + o = old[oldStart] + v = vnodes[start] + oFromPool = hasPool && oldStart >= originalOldLength + if (o === v && !oFromPool && !recyclingParent || o == null && v == null) oldStart++, start++ + else if (o == null) { + if (isUnkeyed || v.key == null) { + createNode(parent, vnodes[start], hooks, ns, getNextSibling(old, ++start, originalOldLength, nextSibling)) + } + oldStart++ + } else if (v == null) { + if (isUnkeyed || o.key == null) { + removeNodes(old, start, start + 1, vnodes, recyclingParent) + oldStart++ + } + start++ + } else if (o.key === v.key) { oldStart++, start++ - updateNode(parent, o, v, hooks, getNextSibling(old, oldStart, nextSibling), shouldRecycle, ns) - if (recycling && o.tag === v.tag) insertNode(parent, toFragment(o), nextSibling) - } - else { - var o = old[oldEnd] - if (o === v && !recycling) oldEnd--, start++ + updateNode(parent, o, v, hooks, getNextSibling(old, oldStart, originalOldLength, nextSibling), oFromPool || recyclingParent, ns) + if (oFromPool && o.tag === v.tag) insertNode(parent, toFragment(v), nextSibling) + } else { + o = old[oldEnd] + oFromPool = hasPool && oldEnd >= originalOldLength + if (o === v && !oFromPool && !recyclingParent) oldEnd--, start++ else if (o == null) oldEnd-- else if (v == null) start++ else if (o.key === v.key) { - var shouldRecycle = (pool != null && oldEnd >= old.length - pool.length) || ((pool == null) && recycling) - updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), shouldRecycle, ns) - if (recycling || start < end) insertNode(parent, toFragment(o), getNextSibling(old, oldStart, nextSibling)) + updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, originalOldLength, nextSibling), oFromPool || recyclingParent, ns) + if (oFromPool && o.tag === v.tag || start < end) insertNode(parent, toFragment(v), getNextSibling(old, oldStart, originalOldLength, nextSibling)) oldEnd--, start++ } else break } } while (oldEnd >= oldStart && end >= start) { - var o = old[oldEnd], v = vnodes[end] - if (o === v && !recycling) oldEnd--, end-- + o = old[oldEnd] + v = vnodes[end] + oFromPool = hasPool && oldEnd >= originalOldLength + if (o === v && !oFromPool && !recyclingParent) oldEnd--, end-- else if (o == null) oldEnd-- else if (v == null) end-- else if (o.key === v.key) { - var shouldRecycle = (pool != null && oldEnd >= old.length - pool.length) || ((pool == null) && recycling) - updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), shouldRecycle, ns) - if (recycling && o.tag === v.tag) insertNode(parent, toFragment(o), nextSibling) + updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, originalOldLength, nextSibling), oFromPool || recyclingParent, ns) + if (oFromPool && o.tag === v.tag) insertNode(parent, toFragment(v), nextSibling) if (o.dom != null) nextSibling = o.dom oldEnd--, end-- - } - else { + } else { if (!map) map = getKeyMap(old, oldEnd) if (v != null) { var oldIndex = map[v.key] if (oldIndex != null) { - var movable = old[oldIndex] - var shouldRecycle = (pool != null && oldIndex >= old.length - pool.length) || ((pool == null) && recycling) - 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 { + o = old[oldIndex] + oFromPool = hasPool && oldIndex >= originalOldLength + updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, originalOldLength, nextSibling), oFromPool || recyclingParent, ns) + insertNode(parent, toFragment(v), nextSibling) + o.skip = true + if (o.dom != null) nextSibling = o.dom + } else { var dom = createNode(parent, v, hooks, ns, nextSibling) nextSibling = dom } @@ -605,14 +740,22 @@ var coreRenderer = function($window) { if (end < start) break } createNodes(parent, vnodes, start, end + 1, hooks, nextSibling, ns) - removeNodes(old, oldStart, oldEnd + 1, vnodes) + removeNodes(old, oldStart, Math.min(oldEnd + 1, originalOldLength), vnodes, recyclingParent) + if (hasPool) { + var limit = Math.max(oldStart, originalOldLength) + for (; oldEnd >= limit; oldEnd--) { + if (old[oldEnd].skip) old[oldEnd].skip = false + else addToPool(old[oldEnd], vnodes) + } + } } } + // when recycling, we're re-using an old DOM node, but firing the oninit/oncreate hooks + // instead of onbeforeupdate/onupdate. function updateNode(parent, old, vnode, hooks, nextSibling, recycling, ns) { var oldTag = old.tag, tag = vnode.tag if (oldTag === tag) { vnode.state = old.state - vnode._state = old._state vnode.events = old.events if (!recycling && shouldNotUpdate(vnode, old)) return if (typeof oldTag === "string") { @@ -633,7 +776,7 @@ var coreRenderer = function($window) { else updateComponent(parent, old, vnode, hooks, nextSibling, recycling, ns) } else { - removeNode(old, null) + removeNode(old, null, recycling) createNode(parent, vnode, hooks, ns, nextSibling) } } @@ -692,10 +835,10 @@ var coreRenderer = function($window) { if (recycling) { initComponent(vnode, hooks) } else { - vnode.instance = Vnode.normalize(vnode._state.view.call(vnode.state, vnode)) + vnode.instance = Vnode.normalize(callHook.call(vnode.state.view, vnode)) if (vnode.instance === vnode) throw Error("A view cannot return the vnode it received as argument") if (vnode.attrs != null) updateLifecycle(vnode.attrs, vnode, hooks) - updateLifecycle(vnode._state, vnode, hooks) + updateLifecycle(vnode.state, vnode, hooks) } if (vnode.instance != null) { if (old.instance == null) createNode(parent, vnode.instance, hooks, ns, nextSibling) @@ -704,7 +847,7 @@ var coreRenderer = function($window) { vnode.domSize = vnode.instance.domSize } else if (old.instance != null) { - removeNode(old.instance, null) + removeNode(old.instance, null, recycling) vnode.dom = undefined vnode.domSize = 0 } @@ -748,14 +891,16 @@ var coreRenderer = function($window) { } else return vnode.dom } - function getNextSibling(vnodes, i, nextSibling) { - for (; i < vnodes.length; i++) { + // the vnodes array may hold items that come from the pool (after `limit`) they should + // be ignored + function getNextSibling(vnodes, i, limit, nextSibling) { + for (; i < limit; i++) { if (vnodes[i] != null && vnodes[i].dom != null) return vnodes[i].dom } return nextSibling } function insertNode(parent, dom, nextSibling) { - if (nextSibling && nextSibling.parentNode) parent.insertBefore(dom, nextSibling) + if (nextSibling) parent.insertBefore(dom, nextSibling) else parent.appendChild(dom) } function setContentEditable(vnode) { @@ -767,35 +912,43 @@ var coreRenderer = function($window) { else if (vnode.text != null || children != null && children.length !== 0) throw new Error("Child node of a contenteditable must be trusted") } //remove - function removeNodes(vnodes, start, end, context) { + function removeNodes(vnodes, start, end, context, recycling) { for (var i = start; i < end; i++) { var vnode = vnodes[i] if (vnode != null) { if (vnode.skip) vnode.skip = false - else removeNode(vnode, context) + else removeNode(vnode, context, recycling) } } } - function removeNode(vnode, context) { + // when a node is removed from a parent that's brought back from the pool, its hooks should + // not fire. + function removeNode(vnode, context, recycling) { var expected = 1, called = 0 - if (vnode.attrs && typeof vnode.attrs.onbeforeremove === "function") { - var result = vnode.attrs.onbeforeremove.call(vnode.state, vnode) - if (result != null && typeof result.then === "function") { - expected++ - result.then(continuation, continuation) + if (!recycling) { + var original = vnode.state + if (vnode.attrs && typeof vnode.attrs.onbeforeremove === "function") { + var result = callHook.call(vnode.attrs.onbeforeremove, vnode) + if (result != null && typeof result.then === "function") { + expected++ + result.then(continuation, continuation) + } } - } - if (typeof vnode.tag !== "string" && typeof vnode._state.onbeforeremove === "function") { - var result = vnode._state.onbeforeremove.call(vnode.state, vnode) - if (result != null && typeof result.then === "function") { - expected++ - result.then(continuation, continuation) + if (typeof vnode.tag !== "string" && typeof vnode.state.onbeforeremove === "function") { + var result = callHook.call(vnode.state.onbeforeremove, vnode) + if (result != null && typeof result.then === "function") { + expected++ + result.then(continuation, continuation) + } } } continuation() function continuation() { if (++called === expected) { - onremove(vnode) + if (!recycling) { + checkState(vnode, original) + onremove(vnode) + } if (vnode.dom) { var count0 = vnode.domSize || 1 if (count0 > 1) { @@ -805,10 +958,7 @@ var coreRenderer = function($window) { } } removeNodeFromDOM(vnode.dom) - if (context != null && vnode.domSize == null && !hasIntegrationMethods(vnode.attrs) && typeof vnode.tag === "string") { //TODO test custom elements - if (!context.pool) context.pool = [vnode] - else context.pool.push(vnode) - } + addToPool(vnode, context) } } } @@ -817,10 +967,16 @@ var coreRenderer = function($window) { var parent = node.parentNode if (parent != null) parent.removeChild(node) } + function addToPool(vnode, context) { + if (context != null && vnode.domSize == null && !hasIntegrationMethods(vnode.attrs) && typeof vnode.tag === "string") { //TODO test custom elements + if (!context.pool) context.pool = [vnode] + else context.pool.push(vnode) + } + } function onremove(vnode) { - if (vnode.attrs && typeof vnode.attrs.onremove === "function") vnode.attrs.onremove.call(vnode.state, vnode) + if (vnode.attrs && typeof vnode.attrs.onremove === "function") callHook.call(vnode.attrs.onremove, vnode) if (typeof vnode.tag !== "string") { - if (typeof vnode._state.onremove === "function") vnode._state.onremove.call(vnode.state, vnode) + if (typeof vnode.state.onremove === "function") callHook.call(vnode.state.onremove, vnode) if (vnode.instance != null) onremove(vnode.instance) } else { var children = vnode.children @@ -900,7 +1056,7 @@ var coreRenderer = function($window) { } } function isFormAttribute(vnode, attr) { - return attr === "value" || attr === "checked" || attr === "selectedIndex" || attr === "selected" && vnode.dom === $doc.activeElement + return attr === "value" || attr === "checked" || attr === "selectedIndex" || attr === "selected" && vnode.dom === $doc.activeElement || vnode.dom.parentNode === $doc.activeElement } function isLifecycleMethod(attr) { return attr === "oninit" || attr === "oncreate" || attr === "onupdate" || attr === "onremove" || attr === "onbeforeremove" || attr === "onbeforeupdate" @@ -974,16 +1130,20 @@ var coreRenderer = function($window) { } //lifecycle function initLifecycle(source, vnode, hooks) { - if (typeof source.oninit === "function") source.oninit.call(vnode.state, vnode) - if (typeof source.oncreate === "function") hooks.push(source.oncreate.bind(vnode.state, vnode)) + if (typeof source.oninit === "function") callHook.call(source.oninit, vnode) + if (typeof source.oncreate === "function") hooks.push(callHook.bind(source.oncreate, vnode)) } function updateLifecycle(source, vnode, hooks) { - if (typeof source.onupdate === "function") hooks.push(source.onupdate.bind(vnode.state, vnode)) + if (typeof source.onupdate === "function") hooks.push(callHook.bind(source.onupdate, vnode)) } function shouldNotUpdate(vnode, old) { var forceVnodeUpdate, forceComponentUpdate - if (vnode.attrs != null && typeof vnode.attrs.onbeforeupdate === "function") forceVnodeUpdate = vnode.attrs.onbeforeupdate.call(vnode.state, vnode, old) - if (typeof vnode.tag !== "string" && typeof vnode._state.onbeforeupdate === "function") forceComponentUpdate = vnode._state.onbeforeupdate.call(vnode.state, vnode, old) + if (vnode.attrs != null && typeof vnode.attrs.onbeforeupdate === "function") { + forceVnodeUpdate = callHook.call(vnode.attrs.onbeforeupdate, vnode, old) + } + if (typeof vnode.tag !== "string" && typeof vnode.state.onbeforeupdate === "function") { + forceComponentUpdate = callHook.call(vnode.state.onbeforeupdate, vnode, old) + } if (!(forceVnodeUpdate === undefined && forceComponentUpdate === undefined) && !forceVnodeUpdate && !forceComponentUpdate) { vnode.dom = old.dom vnode.domSize = old.domSize @@ -1002,9 +1162,9 @@ var coreRenderer = function($window) { if (!Array.isArray(vnodes)) vnodes = [vnodes] updateNodes(dom, dom.vnodes, Vnode.normalizeChildren(vnodes), false, hooks, null, namespace === "http://www.w3.org/1999/xhtml" ? undefined : namespace) dom.vnodes = vnodes - for (var i = 0; i < hooks.length; i++) hooks[i]() // document.activeElement can return null in IE https://developer.mozilla.org/en-US/docs/Web/API/Document/activeElement if (active != null && $doc.activeElement !== active) active.focus() + for (var i = 0; i < hooks.length; i++) hooks[i]() } return {render: render, setEventCallback: setEventCallback} } @@ -1244,7 +1404,7 @@ var _20 = function($window, redrawService0) { } route.get = function() {return currentPath} route.prefix = function(prefix0) {routeService.prefix = prefix0} - route.link = function(vnode1) { + var link = function(options, vnode1) { vnode1.dom.setAttribute("href", routeService.prefix + vnode1.attrs.href) vnode1.dom.onclick = function(e) { if (e.ctrlKey || e.metaKey || e.shiftKey || e.which === 2) return @@ -1252,9 +1412,13 @@ var _20 = function($window, redrawService0) { e.redraw = false var href = this.getAttribute("href") if (href.indexOf(routeService.prefix) === 0) href = href.slice(routeService.prefix.length) - route.set(href, undefined, undefined) + route.set(href, undefined, options) } } + route.link = function(args0) { + if (args0.tag == null) return link.bind(link, args0) + return link({}, args0) + } route.param = function(key3) { if(typeof attrs3 !== "undefined" && typeof key3 !== "undefined") return attrs3[key3] return attrs3 diff --git a/mithril.min.js b/mithril.min.js index 5a8f5c34..3a305535 100644 --- a/mithril.min.js +++ b/mithril.min.js @@ -1,45 +1,46 @@ -(function(){function y(b,d,h,k,q,m){return{tag:b,key:d,attrs:h,children:k,text:q,dom:m,domSize:void 0,state:void 0,_state:void 0,events:void 0,instance:void 0,skip:!1}}function O(b){for(var d in b)if(G.call(b,d))return!1;return!0}function A(b){var d=arguments[1],h=2;if(null==b||"string"!==typeof b&&"function"!==typeof b&&"function"!==typeof b.view)throw Error("The selector must be either a string or a component.");if("string"===typeof b){var k;if(!(k=P[b])){var q="div";for(var m=[],l={};k=R.exec(b);){var r= -k[1],f=k[2];""===r&&""!==f?q=f:"#"===r?l.id=f:"."===r?m.push(f):"["===k[3][0]&&((r=k[6])&&(r=r.replace(/\\(["'])/g,"$1").replace(/\\\\/g,"\\")),"class"===k[4]?m.push(r):l[k[4]]=""===r?r:r||!0)}0a.indexOf("?")?"?":"&";a+=f+d}return a}function l(a){try{return""!==a?JSON.parse(a):null}catch(v){throw Error(a);}}function r(a){return a.responseText}function f(a,b){if("function"===typeof a)if(Array.isArray(b))for(var d=0;dn.status||304===n.status||U.test(a.url))d(f(a.type,b));else{var k=Error(n.responseText);k.code=n.status;k.response=b;h(k)}}catch(g){h(g)}};k&&null!=a.data?n.send(a.data):n.send()});return!0===a.background?v:p(v)},jsonp:function(a,l){var r=h();a=k(a,l);var p=new d(function(d,h){var k=a.callbackName||"_mithril_"+ -Math.round(1E16*Math.random())+"_"+n++,l=b.document.createElement("script");b[k]=function(h){l.parentNode.removeChild(l);d(f(a.type,h));delete b[k]};l.onerror=function(){l.parentNode.removeChild(l);h(Error("JSONP request failed"));delete b[k]};null==a.data&&(a.data={});a.url=q(a.url,a.data);a.data[a.callbackKey||"callback"]=k;l.src=m(a.url,a.data);b.document.documentElement.appendChild(l)});return!0===a.background?p:r(p)},setCompletionCallback:function(a){p=a}}}(window,w),Q=function(b){function d(g, -c,e,a,b,d,k){for(;e=x&&F>=p;){var z=c[x],t=e[p];if(z!==t||b)if(null==z)x++;else if(null==t)p++;else if(z.key===t.key){var B=null!= -C&&x>=c.length-C.length||null==C&&b;x++;p++;l(g,z,t,k,f(c,x,m),B,q);b&&z.tag===t.tag&&n(g,r(z),m)}else if(z=c[v],z!==t||b)if(null==z)v--;else if(null==t)p++;else if(z.key===t.key)B=null!=C&&v>=c.length-C.length||null==C&&b,l(g,z,t,k,f(c,v+1,m),B,q),(b||p=x&&F>=p;){z=c[v];t=e[F];if(z!==t||b)if(null==z)v--;else{if(null!=t)if(z.key===t.key)B=null!=C&&v>=c.length-C.length||null==C&&b,l(g,z,t,k,f(c,v+1,m),B,q),b&&z.tag===t.tag&& -n(g,r(z),m),null!=z.dom&&(m=z.dom),v--;else{if(!u){u=c;z=v;B={};var w;for(w=0;wc.indexOf("?")?"?":"&";c+=e+d}return c}function k(c){try{return""!==c?JSON.parse(c):null}catch(C){throw Error(c);}}function q(c){return c.responseText}function r(c,a){if("function"===typeof c)if(Array.isArray(a))for(var d=0;dm.status||304===m.status||Z.test(c.url))d(r(c.type,a));else{var h=Error(m.responseText);h.code=m.status;h.response=a;e(h)}}catch(H){e(H)}};h&&null!=c.data?m.send(c.data):m.send()});return!0===c.background?C:A(C)},jsonp:function(c,k){var A=e();c=h(c, +k);var q=new d(function(d,e){var h=c.callbackName||"_mithril_"+Math.round(1E16*Math.random())+"_"+m++,k=a.document.createElement("script");a[h]=function(e){k.parentNode.removeChild(k);d(r(c.type,e));delete a[h]};k.onerror=function(){k.parentNode.removeChild(k);e(Error("JSONP request failed"));delete a[h]};null==c.data&&(c.data={});c.url=p(c.url,c.data);c.data[c.callbackKey||"callback"]=h;k.src=l(c.url,c.data);a.document.documentElement.appendChild(k)});return!0===c.background?q:A(q)},setCompletionCallback:function(c){u= +c}}}(window,B),U=function(a){function d(g,b){if(g.state!==b)throw Error("`vnode.state` must not be modified");}function e(g){var b=g.state;try{return this.apply(b,arguments)}finally{d(g,b)}}function h(g,b,f,c,a,d,e){for(;f=v&&t>=n;)if(w=b[v],z=f[n],y=C&&v>=q,w===z&&!y&&!a||null==w&&null==z)v++,n++;else if(null==w)(E||null==z.key)&&p(g,f[n],d,k,u(b,++n,q,e)),v++;else if(null==z){if(E||null==w.key)A(b,n,n+1,f,a),v++;n++}else if(w.key===z.key)v++,n++,r(g,w,z,d,u(b,v,q,e),y||a,k),y&&w.tag===z.tag&&c(g,m(z),e);else if(w=b[l],y=C&&l>=q,w!==z||y||a)if(null==w)l--;else if(null== +z)n++;else if(w.key===z.key)r(g,w,z,d,u(b,l+1,q,e),y||a,k),(y&&w.tag===z.tag||n=v&&t>=n;){w=b[l];z=f[t];y=C&&l>=q;if(w!==z||y||a)if(null==w)l--;else{if(null!=z)if(w.key===z.key)r(g,w,z,d,u(b,l+1,q,e),y||a,k),y&&w.tag===z.tag&&c(g,m(z),e),null!=w.dom&&(e=w.dom),l--;else{if(!I){I=b;E=l;w={};for(y=0;y=q,r(g,w,z,d,u(b,l+1,q,e),y||a,k), +c(g,m(z),e),w.skip=!0,null!=w.dom&&(e=w.dom)):e=p(g,z,d,k,e))}t--}else l--,t--;if(t=g;l--)b[l].skip?b[l].skip=!1:B(b[l],f)}}}function r(g,b,f,a,c,d,h){var n=b.tag;if(n===f.tag){f.state=b.state;f.events=b.events;var A;if(A=!d){var u,E;null!=f.attrs&&"function"===typeof f.attrs.onbeforeupdate&&(u=e.call(f.attrs.onbeforeupdate,f,b));"string"!==typeof f.tag&&"function"===typeof f.state.onbeforeupdate&&(E=e.call(f.state.onbeforeupdate, +f,b));void 0===u&&void 0===E||u||E?A=!1:(f.dom=b.dom,f.domSize=b.domSize,f.instance=b.instance,A=!0)}if(!A)if("string"===typeof n)switch(null!=f.attrs&&(d?(f.state={},K(f.attrs,f,a)):M(f.attrs,f,a)),n){case "#":b.children.toString()!==f.children.toString()&&(b.dom.nodeValue=f.children);f.dom=b.dom;break;case "<":b.children!==f.children?(m(b),l(g,f,c)):(f.dom=b.dom,f.domSize=b.domSize);break;case "[":q(g,b.children,f.children,d,a,c,h);b=0;a=f.children;f.dom=null;if(null!=a){for(d=0;d`-separated string showing the structure of the test specification. +In the below example, `result.context` would be `testing > rocks`. + +```javascript +o.spec("testing", function() { + o.spec("rocks", function() { + o(false).equals(true) + }) +}) +``` + + + --- ## Goals @@ -462,8 +547,8 @@ $o.run() - Do the most common things that the mocha/chai/sinon triad does without having to install 3 different libraries and several dozen dependencies - Disallow configuration in test-space: - Disallow ability to pick between API styles (BDD/TDD/Qunit, assert/should/expect, etc) - - Disallow ability to pick between different reporters - Disallow ability to add custom assertion types + - Provide a default simple reporter - Make assertion code terse, readable and self-descriptive - Have as few assertion types as possible for a workable usage pattern diff --git a/ospec/ospec.js b/ospec/ospec.js index 147e2dc4..9769bccd 100644 --- a/ospec/ospec.js +++ b/ospec/ospec.js @@ -1,11 +1,16 @@ -/* eslint-disable no-bitwise, no-process-exit */ +/* eslint-disable global-require, no-bitwise, no-process-exit */ "use strict" - -module.exports = new function init(name) { +;(function(m) { +if (typeof module !== "undefined") module["exports"] = m() +else window.o = m() +})(function init(name) { var spec = {}, subjects = [], results, only = null, ctx = spec, start, stack = 0, nextTickish, hasProcess = typeof process === "object", hasOwn = ({}).hasOwnProperty if (name != null) spec[name] = ctx = {} + try {throw new Error} catch (e) { + var ospecFileName = e.stack && (/[\/\\](.*?):\d+:\d+/).test(e.stack) ? e.stack.match(/[\/\\](.*?):\d+:\d+/)[1] : null + } function o(subject, predicate) { if (predicate === undefined) { if (results == null) throw new Error("Assertions should not occur outside test definitions") @@ -49,10 +54,35 @@ module.exports = new function init(name) { spy.callCount = 0 return spy } - o.run = function() { + o.cleanStackTrace = function(error) { + // For IE 10+ in quirks mode, and IE 9- in any mode, errors don't have a stack + if (error.stack == null) return "" + var i = 0, header = error.message ? error.name + ": " + error.message : error.name, stack + // some environments add the name and message to the stack trace + if (error.stack.indexOf(header) === 0) { + stack = error.stack.slice(header.length).split(/\r?\n/) + stack.shift() // drop the initial empty string + } else { + stack = error.stack.split(/\r?\n/) + } + if (ospecFileName == null) return stack.join("\n") + // skip ospec-related entries on the stack + while (stack[i].indexOf(ospecFileName) !== -1) i++ + // now we're in user code + return stack[i] + } + o.run = function(reporter) { results = [] start = new Date - test(spec, [], [], report) + test(spec, [], [], function() { + setTimeout(function () { + if (typeof reporter === "function") reporter(results) + else { + var errCount = o.report(results) + if (hasProcess && errCount !== 0) process.exit(1) + } + }) + }) function test(spec, pre, post, finalize) { pre = [].concat(pre, spec["__beforeEach"] || []) @@ -217,7 +247,8 @@ module.exports = new function init(name) { } result.context = subjects.join(" > ") result.message = message - result.error = error.stack + result.error = error + } results.push(result) } @@ -231,13 +262,13 @@ module.exports = new function init(name) { return hasProcess ? "\x1b[31m" + message + "\x1b[0m" : "%c" + message + "%c " } - function report() { - var status = 0 + o.report = function (results) { + var errCount = 0 for (var i = 0, r; r = results[i]; i++) { if (!r.pass) { - var stackTrace = r.error.match(/^(?:(?!Error|[\/\\]ospec[\/\\]ospec\.js).)*$/m) + var stackTrace = o.cleanStackTrace(r.error) console.error(r.context + ":\n" + highlight(r.message) + (stackTrace ? "\n\n" + stackTrace + "\n\n" : ""), hasProcess ? "" : "color:red", hasProcess ? "" : "color:black") - status = 1 + errCount++ } } console.log( @@ -245,10 +276,10 @@ module.exports = new function init(name) { results.length + " assertions completed in " + Math.round(new Date - start) + "ms, " + "of which " + results.filter(function(result){return result.error}).length + " failed" ) - if (hasProcess && status === 1) process.exit(1) + return errCount } - if(hasProcess) { + if (hasProcess) { nextTickish = process.nextTick } else { nextTickish = function fakeFastNextTick(next) { @@ -258,4 +289,4 @@ module.exports = new function init(name) { } return o -} +}) diff --git a/ospec/package.json b/ospec/package.json index 7cfa4595..acc0ae47 100644 --- a/ospec/package.json +++ b/ospec/package.json @@ -1,6 +1,6 @@ { "name": "ospec", - "version": "1.3.0", + "version": "1.4.0", "description": "Noiseless testing framework", "main": "ospec.js", "directories": { diff --git a/ospec/tests/test-ospec.js b/ospec/tests/test-ospec.js index 69f2e1f1..526671c6 100644 --- a/ospec/tests/test-ospec.js +++ b/ospec/tests/test-ospec.js @@ -18,6 +18,77 @@ new function(o) { o.run() }(o) +new function(o) { + var clone = o.new() + + clone.spec("clone", function() { + clone("fail", function() { + clone(true).equals(false) + }) + + clone("pass", function() { + clone(true).equals(true) + }) + }) + + // Predicate test passing on clone results + o.spec("reporting", function() { + o("reports per instance", function(done, timeout) { + timeout(100) // Waiting on clone + + clone.run(function(results) { + o(typeof results).equals("object") + o("length" in results).equals(true) + o(results.length).equals(2)("Two results") + + o("error" in results[0] && "pass" in results[0]).equals(true)("error and pass keys present in failing result") + o(!("error" in results[1]) && "pass" in results[1]).equals(true)("only pass key present in passing result") + o(results[0].pass).equals(false)("Test meant to fail has failed") + o(results[1].pass).equals(true)("Test meant to pass has passed") + + done() + }) + }) + o("o.report() returns the number of failures", function () { + var log = console.log, error = console.error + console.log = o.spy() + console.error = o.spy() + + function makeError(msg) {try{throw msg ? new Error(msg) : new Error} catch(e){return e}} + try { + var errCount = o.report([{pass: true}, {pass: true}]) + + o(errCount).equals(0) + o(console.log.callCount).equals(1) + o(console.error.callCount).equals(0) + + errCount = o.report([ + {pass: false, error: makeError("hey"), message: "hey"} + ]) + + o(errCount).equals(1) + o(console.log.callCount).equals(2) + o(console.error.callCount).equals(1) + + errCount = o.report([ + {pass: false, error: makeError("hey"), message: "hey"}, + {pass: true}, + {pass: false, error: makeError("ho"), message: "ho"} + ]) + + o(errCount).equals(2) + o(console.log.callCount).equals(3) + o(console.error.callCount).equals(3) + } catch (e) { + o(1).equals(0)("Error while testing the reporter") + } + + console.log = log + console.error = error + }) + }) +}(o) + o.spec("ospec", function() { o.spec("sync", function() { var a = 0, b = 0, illegalAssertionThrows = false @@ -149,6 +220,18 @@ o.spec("ospec", function() { }) }) + o.spec("stack trace cleaner", function() { + o("handles line breaks", function() { + try { + throw new Error("line\nbreak") + } catch(error) { + var trace = o.cleanStackTrace(error) + o(trace).notEquals("break") + o(trace.includes("test-ospec.js")).equals(true) + } + }) + }) + o.spec("async promise", function() { var a = 0, b = 0 @@ -188,8 +271,8 @@ o.spec("ospec", function() { }) }) - o("promise functions", async function() { - await wrapPromise(function() { + o("promise functions", function() { + return wrapPromise(function() { o(a).equals(b) o(a).equals(1)("a and b should be initialized") }) diff --git a/package-lock.json b/package-lock.json index 779147ad..8dd68afc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3797,15 +3797,6 @@ "integrity": "sha1-Rb8dny19wJvtgfHDB8Qw5ouEz/4=", "dev": true }, - "string_decoder": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", - "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", - "dev": true, - "requires": { - "safe-buffer": "5.1.1" - } - }, "string-width": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", @@ -3817,6 +3808,15 @@ "strip-ansi": "3.0.1" } }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "dev": true, + "requires": { + "safe-buffer": "5.1.1" + } + }, "stringstream": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz", diff --git a/render/render.js b/render/render.js index 121083ca..a1478384 100644 --- a/render/render.js +++ b/render/render.js @@ -18,6 +18,24 @@ module.exports = function($window) { return vnode.attrs && vnode.attrs.xmlns || nameSpace[vnode.tag] } + //sanity check to discourage people from doing `vnode.state = ...` + function checkState(vnode, original) { + if (vnode.state !== original) throw new Error("`vnode.state` must not be modified") + } + + //Note: the hook is passed as the `this` argument to allow proxying the + //arguments without requiring a full array allocation to do so. It also + //takes advantage of the fact the current `vnode` is the first argument in + //all lifecycle methods. + function callHook(vnode) { + var original = vnode.state + try { + return this.apply(original, arguments) + } finally { + checkState(vnode, original) + } + } + //create function createNodes(parent, vnodes, start, end, hooks, nextSibling, ns) { for (var i = start; i < end; i++) { @@ -121,10 +139,9 @@ module.exports = function($window) { sentinel.$$reentrantLock$$ = true vnode.state = (vnode.tag.prototype != null && typeof vnode.tag.prototype.view === "function") ? new vnode.tag(vnode) : vnode.tag(vnode) } - vnode._state = vnode.state if (vnode.attrs != null) initLifecycle(vnode.attrs, vnode, hooks) - initLifecycle(vnode._state, vnode, hooks) - vnode.instance = Vnode.normalize(vnode._state.view.call(vnode.state, vnode)) + initLifecycle(vnode.state, vnode, hooks) + vnode.instance = Vnode.normalize(callHook.call(vnode.state.view, vnode)) if (vnode.instance === vnode) throw Error("A view cannot return the vnode it received as argument") sentinel.$$reentrantLock$$ = null } @@ -144,86 +161,212 @@ module.exports = function($window) { } //update - function updateNodes(parent, old, vnodes, recycling, hooks, nextSibling, ns) { - if (old === vnodes || old == null && vnodes == null) return + /** + * @param {Element|Fragment} parent - the parent element + * @param {Vnode[] | null} old - the list of vnodes of the last `render()` call for + * this part of the tree + * @param {Vnode[] | null} vnodes - as above, but for the current `render()` call. + * @param {boolean} recyclingParent - was the parent vnode or one of its ancestor + * fetched from the recycling pool? + * @param {Function[]} hooks - an accumulator of post-render hooks (oncreate/onupdate) + * @param {Element | null} nextSibling - the next DOM node if we're dealing with a + * fragment that is not the last item in its + * parent + * @param {'svg' | 'math' | String | null} ns) - the current XML namespace, if any + * @returns void + */ + // This function diffs and patches lists of vnodes, both keyed and unkeyed. + // + // We will: + // + // 1. describe its general structure + // 2. focus on the diff algorithm optimizations + // 3. describe how the recycling pool meshes into this + // 4. discuss DOM node operations. + + // ## Overview: + // + // The updateNodes() function: + // - deals with trivial cases + // - determines whether the lists are keyed or unkeyed + // (Currently we look for the first pair of non-null nodes and deem the lists unkeyed + // if both nodes are unkeyed. TODO (v2) We may later take advantage of the fact that + // mixed diff is not supported and settle on the keyedness of the first vnode we find) + // - diffs them and patches the DOM if needed (that's the brunt of the code) + // - manages the leftovers: after diffing, are there: + // - old nodes left to remove? + // - new nodes to insert? + // - nodes left in the recycling pool? + // deal with them! + // + // The lists are only iterated over once, with an exception for the nodes in `old` that + // are visited in the fourth part of the diff and in the `removeNodes` loop. + + // ## Diffing + // + // There's first a simple diff for unkeyed lists of equal length that eschews the pool. + // + // It is followed by a small section that activates the recycling pool if present, we'll + // ignore it for now. + // + // Then comes the main diff algorithm that is split in four parts (simplifying a bit). + // + // The first part goes through both lists top-down as long as the nodes at each level have + // the same key. This is always true for unkeyed lists that are entirely processed by this + // step. + // + // The second part deals with lists reversals, and traverses one list top-down and the other + // bottom-up (as long as the keys match). + // + // The third part goes through both lists bottom up as long as the keys match. + // + // The first and third sections allow us to deal efficiently with situations where one or + // more contiguous nodes were either inserted into, removed from or re-ordered in an otherwise + // sorted list. They may reduce the number of nodes to be processed in the fourth section. + // + // The fourth section does keyed diff for the situations not covered by the other three. It + // builds a {key: oldIndex} dictionary and uses it to find old nodes that match the keys of + // new ones. + // The nodes from the `old` array that have a match in the new `vnodes` one are marked as + // `vnode.skip: true`. + // + // If there are still nodes in the new `vnodes` array that haven't been matched to old ones, + // they are created. + // The range of old nodes that wasn't covered by the first three sections is passed to + // `removeNodes()`. Those nodes are removed unless marked as `.skip: true`. + // + // Then some pool business happens. + // + // It should be noted that the description of the four sections above is not perfect, because those + // parts are actually implemented as only two loops, one for the first two parts, and one for + // the other two. I'm not sure it wins us anything except maybe a few bytes of file size. + + // ## The pool + // + // `old.pool` is an optional array that holds the vnodes that have been previously removed + // from the DOM at this level (provided they met the pool eligibility criteria). + // + // If the `old`, `old.pool` and `vnodes` meet some criteria described in `isRecyclable`, the + // elements of the pool are appended to the `old` array, which enables the reconciler to find + // them. + // + // While this is optimal for unkeyed diff and map-based keyed diff (the fourth diff part), + // that strategy clashes with the second and third parts of the main diff algo, because + // the end of the old list is now filled with the nodes of the pool. + // + // To determine if a vnode was brought back from the pool, we look at its position in the + // `old` array (see the various `oFromPool` definitions). That information is important + // in three circumstances: + // - If the old and the new vnodes are the same object (`===`), diff is not performed unless + // the old node comes from the pool (since it must be recycled/re-created). + // - The value of `oFromPool` is passed as the `recycling` parameter of `updateNode()` (whether + // the parent is being recycled is also factred in here) + // - It is used in the DOM node insertion logic (see below) + // + // At the very end of `updateNodes()`, the nodes in the pool that haven't been picked back + // are put in the new pool for the next render phase. + // + // The pool eligibility and `isRecyclable()` criteria are to be updated as part of #1675. + + // ## DOM node operations + // + // In most cases `updateNode()` and `createNode()` perform the DOM operations. However, + // this is not the case if the node moved (second and fourth part of the diff algo), or + // if the node was brough back from the pool and both the old and new nodes have the same + // `.tag` value (when the `.tag` differ, `updateNode()` does the insertion). + // + // The fourth part of the diff currently inserts nodes unconditionally, leading to issues + // like #1791 and #1999. We need to be smarter about those situations where adjascent old + // nodes remain together in the new list in a way that isn't covered by parts one and + // three of the diff algo. + + function updateNodes(parent, old, vnodes, recyclingParent, hooks, nextSibling, ns) { + if (old === vnodes && !recyclingParent || old == null && vnodes == null) return else if (old == null) createNodes(parent, vnodes, 0, vnodes.length, hooks, nextSibling, ns) - else if (vnodes == null) removeNodes(old, 0, old.length, vnodes) + else if (vnodes == null) removeNodes(old, 0, old.length, vnodes, recyclingParent) else { - 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) createNode(parent, 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), recycling, ns) - } - return + var start = 0, commonLength = Math.min(old.length, vnodes.length), originalOldLength = old.length, hasPool = false, isUnkeyed = false + for(; start < commonLength; start++) { + if (old[start] != null && vnodes[start] != null) { + if (old[start].key == null && vnodes[start].key == null) isUnkeyed = true + break } } - recycling = recycling || isRecyclable(old, vnodes) - if (recycling) { - var pool = old.pool + if (isUnkeyed && originalOldLength === vnodes.length) { + for (start = 0; start < originalOldLength; start++) { + if (old[start] === vnodes[start] && !recyclingParent || old[start] == null && vnodes[start] == null) continue + else if (old[start] == null) createNode(parent, vnodes[start], hooks, ns, getNextSibling(old, start + 1, originalOldLength, nextSibling)) + else if (vnodes[start] == null) removeNodes(old, start, start + 1, vnodes, recyclingParent) + else updateNode(parent, old[start], vnodes[start], hooks, getNextSibling(old, start + 1, originalOldLength, nextSibling), recyclingParent, ns) + } + return + } + + if (isRecyclable(old, vnodes)) { + hasPool = true old = old.concat(old.pool) } - var oldStart = 0, start = 0, oldEnd = old.length - 1, end = vnodes.length - 1, map + var oldStart = start = 0, oldEnd = old.length - 1, end = vnodes.length - 1, map, o, v, oFromPool + 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) { - var shouldRecycle = (pool != null && oldStart >= old.length - pool.length) || ((pool == null) && recycling) + o = old[oldStart] + v = vnodes[start] + oFromPool = hasPool && oldStart >= originalOldLength + if (o === v && !oFromPool && !recyclingParent || o == null && v == null) oldStart++, start++ + else if (o == null) { + if (isUnkeyed || v.key == null) { + createNode(parent, vnodes[start], hooks, ns, getNextSibling(old, ++start, originalOldLength, nextSibling)) + } + oldStart++ + } else if (v == null) { + if (isUnkeyed || o.key == null) { + removeNodes(old, start, start + 1, vnodes, recyclingParent) + oldStart++ + } + start++ + } else if (o.key === v.key) { oldStart++, start++ - updateNode(parent, o, v, hooks, getNextSibling(old, oldStart, nextSibling), shouldRecycle, ns) - if (recycling && o.tag === v.tag) insertNode(parent, toFragment(o), nextSibling) - } - else { - var o = old[oldEnd] - if (o === v && !recycling) oldEnd--, start++ + updateNode(parent, o, v, hooks, getNextSibling(old, oldStart, originalOldLength, nextSibling), oFromPool || recyclingParent, ns) + if (oFromPool && o.tag === v.tag) insertNode(parent, toFragment(v), nextSibling) + } else { + o = old[oldEnd] + oFromPool = hasPool && oldEnd >= originalOldLength + if (o === v && !oFromPool && !recyclingParent) oldEnd--, start++ else if (o == null) oldEnd-- else if (v == null) start++ else if (o.key === v.key) { - var shouldRecycle = (pool != null && oldEnd >= old.length - pool.length) || ((pool == null) && recycling) - updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), shouldRecycle, ns) - if (recycling || start < end) insertNode(parent, toFragment(o), getNextSibling(old, oldStart, nextSibling)) + updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, originalOldLength, nextSibling), oFromPool || recyclingParent, ns) + if (oFromPool && o.tag === v.tag || start < end) insertNode(parent, toFragment(v), getNextSibling(old, oldStart, originalOldLength, nextSibling)) oldEnd--, start++ } else break } } while (oldEnd >= oldStart && end >= start) { - var o = old[oldEnd], v = vnodes[end] - if (o === v && !recycling) oldEnd--, end-- + o = old[oldEnd] + v = vnodes[end] + oFromPool = hasPool && oldEnd >= originalOldLength + if (o === v && !oFromPool && !recyclingParent) oldEnd--, end-- else if (o == null) oldEnd-- else if (v == null) end-- else if (o.key === v.key) { - var shouldRecycle = (pool != null && oldEnd >= old.length - pool.length) || ((pool == null) && recycling) - updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, nextSibling), shouldRecycle, ns) - if (recycling && o.tag === v.tag) insertNode(parent, toFragment(o), nextSibling) + updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, originalOldLength, nextSibling), oFromPool || recyclingParent, ns) + if (oFromPool && o.tag === v.tag) insertNode(parent, toFragment(v), nextSibling) if (o.dom != null) nextSibling = o.dom oldEnd--, end-- - } - else { + } else { if (!map) map = getKeyMap(old, oldEnd) if (v != null) { var oldIndex = map[v.key] if (oldIndex != null) { - var movable = old[oldIndex] - var shouldRecycle = (pool != null && oldIndex >= old.length - pool.length) || ((pool == null) && recycling) - 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 { + o = old[oldIndex] + oFromPool = hasPool && oldIndex >= originalOldLength + updateNode(parent, o, v, hooks, getNextSibling(old, oldEnd + 1, originalOldLength, nextSibling), oFromPool || recyclingParent, ns) + insertNode(parent, toFragment(v), nextSibling) + o.skip = true + if (o.dom != null) nextSibling = o.dom + } else { var dom = createNode(parent, v, hooks, ns, nextSibling) nextSibling = dom } @@ -233,14 +376,22 @@ module.exports = function($window) { if (end < start) break } createNodes(parent, vnodes, start, end + 1, hooks, nextSibling, ns) - removeNodes(old, oldStart, oldEnd + 1, vnodes) + removeNodes(old, oldStart, Math.min(oldEnd + 1, originalOldLength), vnodes, recyclingParent) + if (hasPool) { + var limit = Math.max(oldStart, originalOldLength) + for (; oldEnd >= limit; oldEnd--) { + if (old[oldEnd].skip) old[oldEnd].skip = false + else addToPool(old[oldEnd], vnodes) + } + } } } + // when recycling, we're re-using an old DOM node, but firing the oninit/oncreate hooks + // instead of onbeforeupdate/onupdate. function updateNode(parent, old, vnode, hooks, nextSibling, recycling, ns) { var oldTag = old.tag, tag = vnode.tag if (oldTag === tag) { vnode.state = old.state - vnode._state = old._state vnode.events = old.events if (!recycling && shouldNotUpdate(vnode, old)) return if (typeof oldTag === "string") { @@ -261,7 +412,7 @@ module.exports = function($window) { else updateComponent(parent, old, vnode, hooks, nextSibling, recycling, ns) } else { - removeNode(old, null) + removeNode(old, null, recycling) createNode(parent, vnode, hooks, ns, nextSibling) } } @@ -321,10 +472,10 @@ module.exports = function($window) { if (recycling) { initComponent(vnode, hooks) } else { - vnode.instance = Vnode.normalize(vnode._state.view.call(vnode.state, vnode)) + vnode.instance = Vnode.normalize(callHook.call(vnode.state.view, vnode)) if (vnode.instance === vnode) throw Error("A view cannot return the vnode it received as argument") if (vnode.attrs != null) updateLifecycle(vnode.attrs, vnode, hooks) - updateLifecycle(vnode._state, vnode, hooks) + updateLifecycle(vnode.state, vnode, hooks) } if (vnode.instance != null) { if (old.instance == null) createNode(parent, vnode.instance, hooks, ns, nextSibling) @@ -333,7 +484,7 @@ module.exports = function($window) { vnode.domSize = vnode.instance.domSize } else if (old.instance != null) { - removeNode(old.instance, null) + removeNode(old.instance, null, recycling) vnode.dom = undefined vnode.domSize = 0 } @@ -377,15 +528,17 @@ module.exports = function($window) { } else return vnode.dom } - function getNextSibling(vnodes, i, nextSibling) { - for (; i < vnodes.length; i++) { + // the vnodes array may hold items that come from the pool (after `limit`) they should + // be ignored + function getNextSibling(vnodes, i, limit, nextSibling) { + for (; i < limit; i++) { if (vnodes[i] != null && vnodes[i].dom != null) return vnodes[i].dom } return nextSibling } function insertNode(parent, dom, nextSibling) { - if (nextSibling && nextSibling.parentNode) parent.insertBefore(dom, nextSibling) + if (nextSibling) parent.insertBefore(dom, nextSibling) else parent.appendChild(dom) } @@ -399,35 +552,43 @@ module.exports = function($window) { } //remove - function removeNodes(vnodes, start, end, context) { + function removeNodes(vnodes, start, end, context, recycling) { for (var i = start; i < end; i++) { var vnode = vnodes[i] if (vnode != null) { if (vnode.skip) vnode.skip = false - else removeNode(vnode, context) + else removeNode(vnode, context, recycling) } } } - function removeNode(vnode, context) { + // when a node is removed from a parent that's brought back from the pool, its hooks should + // not fire. + function removeNode(vnode, context, recycling) { var expected = 1, called = 0 - if (vnode.attrs && typeof vnode.attrs.onbeforeremove === "function") { - var result = vnode.attrs.onbeforeremove.call(vnode.state, vnode) - if (result != null && typeof result.then === "function") { - expected++ - result.then(continuation, continuation) + if (!recycling) { + var original = vnode.state + if (vnode.attrs && typeof vnode.attrs.onbeforeremove === "function") { + var result = callHook.call(vnode.attrs.onbeforeremove, vnode) + if (result != null && typeof result.then === "function") { + expected++ + result.then(continuation, continuation) + } } - } - if (typeof vnode.tag !== "string" && typeof vnode._state.onbeforeremove === "function") { - var result = vnode._state.onbeforeremove.call(vnode.state, vnode) - if (result != null && typeof result.then === "function") { - expected++ - result.then(continuation, continuation) + if (typeof vnode.tag !== "string" && typeof vnode.state.onbeforeremove === "function") { + var result = callHook.call(vnode.state.onbeforeremove, vnode) + if (result != null && typeof result.then === "function") { + expected++ + result.then(continuation, continuation) + } } } continuation() function continuation() { if (++called === expected) { - onremove(vnode) + if (!recycling) { + checkState(vnode, original) + onremove(vnode) + } if (vnode.dom) { var count = vnode.domSize || 1 if (count > 1) { @@ -437,10 +598,7 @@ module.exports = function($window) { } } removeNodeFromDOM(vnode.dom) - if (context != null && vnode.domSize == null && !hasIntegrationMethods(vnode.attrs) && typeof vnode.tag === "string") { //TODO test custom elements - if (!context.pool) context.pool = [vnode] - else context.pool.push(vnode) - } + addToPool(vnode, context) } } } @@ -449,10 +607,16 @@ module.exports = function($window) { var parent = node.parentNode if (parent != null) parent.removeChild(node) } + function addToPool(vnode, context) { + if (context != null && vnode.domSize == null && !hasIntegrationMethods(vnode.attrs) && typeof vnode.tag === "string") { //TODO test custom elements + if (!context.pool) context.pool = [vnode] + else context.pool.push(vnode) + } + } function onremove(vnode) { - if (vnode.attrs && typeof vnode.attrs.onremove === "function") vnode.attrs.onremove.call(vnode.state, vnode) + if (vnode.attrs && typeof vnode.attrs.onremove === "function") callHook.call(vnode.attrs.onremove, vnode) if (typeof vnode.tag !== "string") { - if (typeof vnode._state.onremove === "function") vnode._state.onremove.call(vnode.state, vnode) + if (typeof vnode.state.onremove === "function") callHook.call(vnode.state.onremove, vnode) if (vnode.instance != null) onremove(vnode.instance) } else { var children = vnode.children @@ -533,7 +697,7 @@ module.exports = function($window) { } } function isFormAttribute(vnode, attr) { - return attr === "value" || attr === "checked" || attr === "selectedIndex" || attr === "selected" && vnode.dom === $doc.activeElement + return attr === "value" || attr === "checked" || attr === "selectedIndex" || attr === "selected" && vnode.dom === $doc.activeElement || vnode.dom.parentNode === $doc.activeElement } function isLifecycleMethod(attr) { return attr === "oninit" || attr === "oncreate" || attr === "onupdate" || attr === "onremove" || attr === "onbeforeremove" || attr === "onbeforeupdate" @@ -611,16 +775,20 @@ module.exports = function($window) { //lifecycle function initLifecycle(source, vnode, hooks) { - if (typeof source.oninit === "function") source.oninit.call(vnode.state, vnode) - if (typeof source.oncreate === "function") hooks.push(source.oncreate.bind(vnode.state, vnode)) + if (typeof source.oninit === "function") callHook.call(source.oninit, vnode) + if (typeof source.oncreate === "function") hooks.push(callHook.bind(source.oncreate, vnode)) } function updateLifecycle(source, vnode, hooks) { - if (typeof source.onupdate === "function") hooks.push(source.onupdate.bind(vnode.state, vnode)) + if (typeof source.onupdate === "function") hooks.push(callHook.bind(source.onupdate, vnode)) } function shouldNotUpdate(vnode, old) { var forceVnodeUpdate, forceComponentUpdate - if (vnode.attrs != null && typeof vnode.attrs.onbeforeupdate === "function") forceVnodeUpdate = vnode.attrs.onbeforeupdate.call(vnode.state, vnode, old) - if (typeof vnode.tag !== "string" && typeof vnode._state.onbeforeupdate === "function") forceComponentUpdate = vnode._state.onbeforeupdate.call(vnode.state, vnode, old) + if (vnode.attrs != null && typeof vnode.attrs.onbeforeupdate === "function") { + forceVnodeUpdate = callHook.call(vnode.attrs.onbeforeupdate, vnode, old) + } + if (typeof vnode.tag !== "string" && typeof vnode.state.onbeforeupdate === "function") { + forceComponentUpdate = callHook.call(vnode.state.onbeforeupdate, vnode, old) + } if (!(forceVnodeUpdate === undefined && forceComponentUpdate === undefined) && !forceVnodeUpdate && !forceComponentUpdate) { vnode.dom = old.dom vnode.domSize = old.domSize @@ -642,9 +810,9 @@ module.exports = function($window) { if (!Array.isArray(vnodes)) vnodes = [vnodes] updateNodes(dom, dom.vnodes, Vnode.normalizeChildren(vnodes), false, hooks, null, namespace === "http://www.w3.org/1999/xhtml" ? undefined : namespace) dom.vnodes = vnodes - for (var i = 0; i < hooks.length; i++) hooks[i]() // document.activeElement can return null in IE https://developer.mozilla.org/en-US/docs/Web/API/Document/activeElement if (active != null && $doc.activeElement !== active) active.focus() + for (var i = 0; i < hooks.length; i++) hooks[i]() } return {render: render, setEventCallback: setEventCallback} diff --git a/render/tests/test-attributes.js b/render/tests/test-attributes.js index d54ed9d4..62cb4196 100644 --- a/render/tests/test-attributes.js +++ b/render/tests/test-attributes.js @@ -462,6 +462,19 @@ o.spec("attributes", function() { {tag:"option", attrs: {value: ""}} ]} } + /* FIXME + This incomplete test is meant for testing #1916. + However it cannot be completed until #1978 is addressed + which is a lack a working select.selected / option.selected + attribute. Ask isiahmeadows. + + o("render select options", function() { + var select = {tag: "select", selectedIndex: 0, children: [ + {tag:"option", attrs: {value: "1", selected: ""}} + ]} + render(root, select) + }) + */ o("can be set as text", function() { var a = makeSelect() var b = makeSelect("2") diff --git a/render/tests/test-component.js b/render/tests/test-component.js index c391ddd2..ea3d1624 100644 --- a/render/tests/test-component.js +++ b/render/tests/test-component.js @@ -764,97 +764,6 @@ o.spec("component", function() { o(attrs[hook].callCount).equals(methods[hook].callCount)(hook) }) }) - o("lifecycle timing megatest (for a single component with the state overwritten)", function() { - var methods = { - view: o.spy(function(vnode) { - o(vnode.state).equals(1) - return "" - }) - } - var attrs = {} - var hooks = [ - "oninit", "oncreate", "onbeforeupdate", - "onupdate", "onbeforeremove", "onremove" - ] - hooks.forEach(function(hook) { - // the `attrs` hooks are called before the component ones - attrs[hook] = o.spy(function(vnode) { - o(vnode.state).equals(1) - o(attrs[hook].callCount).equals(methods[hook].callCount + 1) - }) - methods[hook] = o.spy(function(vnode) { - o(vnode.state).equals(1) - o(attrs[hook].callCount).equals(methods[hook].callCount) - }) - }) - - var attrsOninit = attrs.oninit - var methodsOninit = methods.oninit - attrs.oninit = o.spy(function(vnode){ - vnode.state = 1 - return attrsOninit.call(this, vnode) - }) - methods.oninit = o.spy(function(vnode){ - vnode.state = 1 - return methodsOninit.call(this, vnode) - }) - - var component = createComponent(methods) - - o(methods.view.callCount).equals(0) - o(methods.oninit.callCount).equals(0) - o(methods.oncreate.callCount).equals(0) - o(methods.onbeforeupdate.callCount).equals(0) - o(methods.onupdate.callCount).equals(0) - o(methods.onbeforeremove.callCount).equals(0) - o(methods.onremove.callCount).equals(0) - - hooks.forEach(function(hook) { - o(attrs[hook].callCount).equals(methods[hook].callCount)(hook) - }) - - render(root, [{tag: component, attrs: attrs}]) - - o(methods.view.callCount).equals(1) - o(methods.oninit.callCount).equals(1) - o(methods.oncreate.callCount).equals(1) - o(methods.onbeforeupdate.callCount).equals(0) - o(methods.onupdate.callCount).equals(0) - o(methods.onbeforeremove.callCount).equals(0) - o(methods.onremove.callCount).equals(0) - - hooks.forEach(function(hook) { - o(attrs[hook].callCount).equals(methods[hook].callCount)(hook) - }) - - render(root, [{tag: component, attrs: attrs}]) - - o(methods.view.callCount).equals(2) - o(methods.oninit.callCount).equals(1) - o(methods.oncreate.callCount).equals(1) - o(methods.onbeforeupdate.callCount).equals(1) - o(methods.onupdate.callCount).equals(1) - o(methods.onbeforeremove.callCount).equals(0) - o(methods.onremove.callCount).equals(0) - - hooks.forEach(function(hook) { - o(attrs[hook].callCount).equals(methods[hook].callCount)(hook) - }) - - render(root, []) - - o(methods.view.callCount).equals(2) - o(methods.oninit.callCount).equals(1) - o(methods.oncreate.callCount).equals(1) - o(methods.onbeforeupdate.callCount).equals(1) - o(methods.onupdate.callCount).equals(1) - o(methods.onbeforeremove.callCount).equals(1) - o(methods.onremove.callCount).equals(1) - - hooks.forEach(function(hook) { - o(attrs[hook].callCount).equals(methods[hook].callCount)(hook) - }) - }) o("hook state and arguments validation", function(){ var methods = { view: o.spy(function(vnode) { diff --git a/render/tests/test-input.js b/render/tests/test-input.js index d61bad54..1f9a24d7 100644 --- a/render/tests/test-input.js +++ b/render/tests/test-input.js @@ -30,6 +30,16 @@ o.spec("form inputs", function() { o($window.document.activeElement).equals(input.dom) }) + o("maintains focus when changed manually in hook", function() { + var input = {tag: "input", attrs: {oncreate: function() { + input.dom.focus(); + }}}; + + render(root, [input]) + + o($window.document.activeElement).equals(input.dom) + }) + o("syncs input value if DOM value differs from vdom value", function() { var input = {tag: "input", attrs: {value: "aaa", oninput: function() {}}} var updated = {tag: "input", attrs: {value: "aaa", oninput: function() {}}} diff --git a/render/tests/test-onbeforeremove.js b/render/tests/test-onbeforeremove.js index 0e83d4a0..834f7510 100644 --- a/render/tests/test-onbeforeremove.js +++ b/render/tests/test-onbeforeremove.js @@ -36,10 +36,7 @@ o.spec("onbeforeremove", function() { o(update.callCount).equals(0) }) o("calls onbeforeremove when removing element", function(done) { - var vnode = {tag: "div", attrs: { - oninit: function() {vnode.state = {}}, - onbeforeremove: remove - }} + var vnode = {tag: "div", attrs: {onbeforeremove: remove}} render(root, [vnode]) render(root, []) @@ -47,6 +44,7 @@ o.spec("onbeforeremove", function() { function remove(node) { o(node).equals(vnode) o(this).equals(vnode.state) + o(this != null && typeof this === "object").equals(true) o(root.childNodes.length).equals(1) o(root.firstChild).equals(vnode.dom) diff --git a/render/tests/test-oninit.js b/render/tests/test-oninit.js index 4d94cae4..f6ffb873 100644 --- a/render/tests/test-oninit.js +++ b/render/tests/test-oninit.js @@ -199,4 +199,27 @@ o.spec("oninit", function() { o(vnode.dom.oninit).equals(undefined) o(vnode.dom.attributes["oninit"]).equals(undefined) }) + + o("No spurious oninit calls in mapped keyed diff when the pool is involved (#1992)", function () { + var oninit1 = o.spy() + var oninit2 = o.spy() + var oninit3 = o.spy() + + render(root, [ + {tag: "p", key: 1, attrs: {oninit: oninit1}}, + {tag: "p", key: 2, attrs: {oninit: oninit2}}, + {tag: "p", key: 3, attrs: {oninit: oninit3}}, + ]) + render(root, [ + {tag: "p", key: 1, attrs: {oninit: oninit1}}, + {tag: "p", key: 3, attrs: {oninit: oninit3}}, + ]) + render(root, [ + {tag: "p", key: 3, attrs: {oninit: oninit3}}, + ]) + + o(oninit1.callCount).equals(1) + o(oninit2.callCount).equals(1) + o(oninit3.callCount).equals(1) + }) }) diff --git a/render/tests/test-onremove.js b/render/tests/test-onremove.js index f509e704..5ab20caf 100644 --- a/render/tests/test-onremove.js +++ b/render/tests/test-onremove.js @@ -101,6 +101,7 @@ o.spec("onremove", function() { render(root, temp) render(root, updated) + o(vnodes[0].dom).equals(updated[0].dom) o(remove.callCount).equals(1) }) o("does not recycle when there's an onremove", function() { @@ -132,7 +133,7 @@ o.spec("onremove", function() { }) render(root, {tag: comp}) render(root, null) - + o(spy.callCount).equals(1) }) o("calls onremove on nested component child", function() { @@ -148,7 +149,7 @@ o.spec("onremove", function() { }) render(root, {tag: comp}) render(root, null) - + o(spy.callCount).equals(1) }) o("doesn't call onremove on children when the corresponding view returns null (after removing the parent)", function() { @@ -191,6 +192,28 @@ o.spec("onremove", function() { o(spy.callCount).equals(0) o(threw).equals(false) }) + o("onremove doesn't fire on nodes that go from pool to pool (#1990)", function() { + var onremove = o.spy(); + + render(root, [m("div", m("div")), m("div", m("div", {onremove: onremove}))]); + render(root, [m("div", m("div"))]); + render(root, []); + + o(onremove.callCount).equals(1) + }) + o("doesn't fire when removing the children of a node that's brought back from the pool (#1991 part 2)", function() { + var onremove = o.spy() + var vnode = {tag: "div", key: 1, children: [{tag: "div", attrs: {onremove: onremove}}]} + var temp = {tag: "div", key: 2} + var updated = {tag: "div", key: 1, children: [{tag: "p"}]} + + render(root, [vnode]) + render(root, [temp]) + render(root, [updated]) + + o(vnode.dom).equals(updated.dom) + o(onremove.callCount).equals(1) + }) }) }) }) \ No newline at end of file diff --git a/render/tests/test-updateNodes.js b/render/tests/test-updateNodes.js index fa5231ea..65d81916 100644 --- a/render/tests/test-updateNodes.js +++ b/render/tests/test-updateNodes.js @@ -839,6 +839,19 @@ o.spec("updateNodes", function() { o(root.childNodes[0].nodeName).equals("A") o(root.childNodes[1].nodeName).equals("B") }) + o("onremove doesn't fire from nodes in the pool (#1990)", function () { + var onremove = o.spy() + render(root, [ + {tag: "div", children: [{tag: "div", attrs: {onremove: onremove}}]}, + {tag: "div", children: [{tag: "div", attrs: {onremove: onremove}}]} + ]) + render(root, [ + {tag: "div", children: [{tag: "div", attrs: {onremove: onremove}}]} + ]) + render(root,[]) + + o(onremove.callCount).equals(2) + }) o("cached, non-keyed nodes skip diff", function () { var onupdate = o.spy(); var cached = {tag:"a", attrs:{onupdate: onupdate}} @@ -857,6 +870,72 @@ o.spec("updateNodes", function() { o(onupdate.callCount).equals(0) }) + o("keyed cached elements are re-initialized when brought back from the pool (#2003)", function () { + var onupdate = o.spy() + var oncreate = o.spy() + var cached = { + tag: "B", key: 1, children: [ + {tag: "A", attrs: {oncreate: oncreate, onupdate: onupdate}, text: "A"} + ] + } + render(root, [{tag: "div", children: [cached]}]) + render(root, []) + render(root, [{tag: "div", children: [cached]}]) + + o(oncreate.callCount).equals(2) + o(onupdate.callCount).equals(0) + }) + + o("unkeyed cached elements are re-initialized when brought back from the pool (#2003)", function () { + var onupdate = o.spy() + var oncreate = o.spy() + var cached = { + tag: "B", children: [ + {tag: "A", attrs: {oncreate: oncreate, onupdate: onupdate}, text: "A"} + ] + } + render(root, [{tag: "div", children: [cached]}]) + render(root, []) + render(root, [{tag: "div", children: [cached]}]) + + o(oncreate.callCount).equals(2) + o(onupdate.callCount).equals(0) + }) + + o("keyed cached elements are re-initialized when brought back from nested pools (#2003)", function () { + var onupdate = o.spy() + var oncreate = o.spy() + var cached = { + tag: "B", key: 1, children: [ + {tag: "A", attrs: {oncreate: oncreate, onupdate: onupdate}, text: "A"} + ] + } + render(root, [{tag: "div", children: [cached]}]) + render(root, [{tag: "div", children: []}]) + render(root, []) + render(root, [{tag: "div", children: [cached]}]) + + o(oncreate.callCount).equals(2) + o(onupdate.callCount).equals(0) + }) + + o("unkeyed cached elements are re-initialized when brought back from nested pools (#2003)", function () { + var onupdate = o.spy() + var oncreate = o.spy() + var cached = { + tag: "B", children: [ + {tag: "A", attrs: {oncreate: oncreate, onupdate: onupdate}, text: "A"} + ] + } + render(root, [{tag: "div", children: [cached]}]) + render(root, [{tag: "div", children: []}]) + render(root, []) + render(root, [{tag: "div", children: [cached]}]) + + o(oncreate.callCount).equals(2) + o(onupdate.callCount).equals(0) + }) + o("null stays in place", function() { var create = o.spy() var update = o.spy() @@ -904,6 +983,62 @@ o.spec("updateNodes", function() { o(vnode.dom).notEquals(updated.dom) }) + o("don't add back elements from fragments that are restored from the pool #1991", function() { + render(root, [ + {tag: "[", children: []}, + {tag: "[", children: []} + ]) + render(root, [ + {tag: "[", children: []}, + {tag: "[", children: [{tag: "div"}]} + ]) + render(root, [ + {tag: "[", children: [null]} + ]) + render(root, [ + {tag: "[", children: []}, + {tag: "[", children: []} + ]) + + o(root.childNodes.length).equals(0) + }) + o("don't add back elements from fragments that are being removed #1991", function() { + render(root, [ + {tag: "[", children: []}, + {tag: "p"}, + ]) + render(root, [ + {tag: "[", children: [{tag: "div", text: 5}]} + ]) + render(root, [ + {tag: "[", children: []}, + {tag: "[", children: []} + ]) + + o(root.childNodes.length).equals(0) + }) + o("handles null values in unkeyed lists of different length (#2003)", function() { + var oncreate = o.spy(); + var onremove = o.spy(); + var onupdate = o.spy(); + function attrs() { + return {oncreate: oncreate, onremove: onremove, onupdate: onupdate} + } + + render(root, [{tag: "div", attrs: attrs()}, null]); + render(root, [null, {tag: "div", attrs: attrs()}, null]); + + o(oncreate.callCount).equals(2) + o(onremove.callCount).equals(1) + o(onupdate.callCount).equals(0) + }) + o("don't fetch the nextSibling from the pool", function() { + render(root, [{tag: "[", children: [{tag: "div", key: 1}, {tag: "div", key: 2}]}, {tag: "p"}]) + render(root, [{tag: "[", children: []}, {tag: "p"}]) + render(root, [{tag: "[", children: [{tag: "div", key: 2}, {tag: "div", key: 1}]}, {tag: "p"}]) + + o([].map.call(root.childNodes, function(el) {return el.nodeName})).deepEquals(["DIV", "DIV", "P"]) + }) components.forEach(function(cmp){ o.spec(cmp.kind, function(){ var createComponent = cmp.create diff --git a/render/vnode.js b/render/vnode.js index ce137703..13ed393f 100644 --- a/render/vnode.js +++ b/render/vnode.js @@ -1,7 +1,7 @@ "use strict" function Vnode(tag, key, attrs, children, text, dom) { - return {tag: tag, key: key, attrs: attrs, children: children, text: text, dom: dom, domSize: undefined, state: undefined, _state: undefined, events: undefined, instance: undefined, skip: false} + return {tag: tag, key: key, attrs: attrs, children: children, text: text, dom: dom, domSize: undefined, state: undefined, events: undefined, instance: undefined, skip: false} } Vnode.normalize = function(node) { if (Array.isArray(node)) return Vnode("[", undefined, undefined, Vnode.normalizeChildren(node), undefined, undefined) diff --git a/request/request.js b/request/request.js index 7e4ec744..8424f889 100644 --- a/request/request.js +++ b/request/request.js @@ -75,6 +75,8 @@ module.exports = function($window, Promise) { } if (args.withCredentials) xhr.withCredentials = args.withCredentials + if (args.timeout) xhr.timeout = args.timeout + for (var key in args.headers) if ({}.hasOwnProperty.call(args.headers, key)) { xhr.setRequestHeader(key, args.headers[key]) } @@ -88,7 +90,7 @@ module.exports = function($window, Promise) { if (xhr.readyState === 4) { try { var response = (args.extract !== extract) ? args.extract(xhr, args) : args.deserialize(args.extract(xhr, args)) - if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304 || FILE_PROTOCOL_REGEX.test(args.url)) { + if (args.extract !== extract || (xhr.status >= 200 && xhr.status < 300) || xhr.status === 304 || FILE_PROTOCOL_REGEX.test(args.url)) { resolve(cast(args.type, response)) } else { diff --git a/request/tests/test-request.js b/request/tests/test-request.js index 94e7e172..52f840ef 100644 --- a/request/tests/test-request.js +++ b/request/tests/test-request.js @@ -230,7 +230,7 @@ o.spec("xhr", function() { } }) xhr({method: "GET", url: "/item", deserialize: deserialize}).then(function(data) { - o(data).equals('{"test":123}') + o(data).equals("{\"test\":123}") }).then(done) }) o("deserialize parameter works in POST", function(done) { @@ -244,7 +244,7 @@ o.spec("xhr", function() { } }) xhr({method: "POST", url: "/item", deserialize: deserialize}).then(function(data) { - o(data).equals('{"test":123}') + o(data).equals("{\"test\":123}") }).then(done) }) o("extract parameter works in GET", function(done) { @@ -258,7 +258,7 @@ o.spec("xhr", function() { } }) xhr({method: "GET", url: "/item", extract: extract}).then(function(data) { - o(data).equals('{"test":123}') + o(data).equals("{\"test\":123}") }).then(done) }) o("extract parameter works in POST", function(done) { @@ -272,7 +272,7 @@ o.spec("xhr", function() { } }) xhr({method: "POST", url: "/item", extract: extract}).then(function(data) { - o(data).equals('{"test":123}') + o(data).equals("{\"test\":123}") }).then(done) }) o("ignores deserialize if extract is defined", function(done) { @@ -435,6 +435,20 @@ o.spec("xhr", function() { done() }) }) + o("set timeout to xhr instance", function() { + mock.$defineRoutes({ + "GET /item": function() { + return {status: 200, responseText: ""} + } + }) + return xhr({ + method: "GET", url: "/item", + timeout: 42, + config: function(xhr) { + o(xhr.timeout).equals(42) + } + }) + }) /*o("data maintains after interpolate", function() { mock.$defineRoutes({ "PUT /items/:x": function() { @@ -519,5 +533,35 @@ o.spec("xhr", function() { o(e instanceof Error).equals(true) }).then(done) }) + o("does not reject on status error code when extract provided", function(done) { + mock.$defineRoutes({ + "GET /item": function() { + return {status: 500, responseText: JSON.stringify({message: "error"})} + } + }) + xhr({ + method: "GET", url: "/item", + extract: function(xhr) {return JSON.parse(xhr.responseText)} + }).then(function(data) { + o(data.message).equals("error") + done() + }) + }) + o("rejects on error in extract", function(done) { + mock.$defineRoutes({ + "GET /item": function() { + return {status: 200, responseText: JSON.stringify({a: 1})} + } + }) + xhr({ + method: "GET", url: "/item", + extract: function() {throw new Error("error")} + }).catch(function(e) { + o(e instanceof Error).equals(true) + o(e.message).equals("error") + }).then(function() { + done() + }) + }) }) }) diff --git a/test-utils/domMock.js b/test-utils/domMock.js index b8f5d1a3..a4c7c598 100644 --- a/test-utils/domMock.js +++ b/test-utils/domMock.js @@ -37,6 +37,30 @@ module.exports = function(options) { function isModernEvent(type) { return type === "transitionstart" || type === "transitionend" || type === "animationstart" || type === "animationend" } + function dispatchEvent(e) { + var stopped = false + e.stopImmediatePropagation = function() { + e.stopPropagation() + stopped = true + } + e.currentTarget = this + if (this._events[e.type] != null) { + for (var i = 0; i < this._events[e.type].handlers.length; i++) { + var useCapture = this._events[e.type].options[i].capture + if (useCapture && e.eventPhase < 3 || !useCapture && e.eventPhase > 1) { + var handler = this._events[e.type].handlers[i] + if (typeof handler === "function") try {handler.call(this, e)} catch(e) {setTimeout(function(){throw e})} + else try {handler.handleEvent(e)} catch(e) {setTimeout(function(){throw e})} + if (stopped) return + } + } + } + // this is inaccurate. Normally the event fires in definition order, including legacy events + // this would require getters/setters for each of them though and we haven't gotten around to + // adding them since it would be at a high perf cost or would entail some heavy refactoring of + // the mocks (prototypes instead of closures). + if (e.eventPhase > 1 && typeof this["on" + e.type] === "function" && !isModernEvent(e.type)) try {this["on" + e.type](e)} catch(e) {setTimeout(function(){throw e})} + } function appendChild(child) { var ancestor = this while (ancestor !== child && ancestor !== null) ancestor = ancestor.parentNode @@ -260,35 +284,99 @@ module.exports = function(options) { else this.setAttribute("class", value) }, focus: function() {activeElement = this}, - addEventListener: function(type, callback) { - if (events[type] == null) events[type] = [callback] - else events[type].push(callback) + addEventListener: function(type, handler, options) { + if (arguments.length > 2) { + if (typeof options === "object" && options != null) throw new TypeError("NYI: addEventListener options") + else if (typeof options !== "boolean") throw new TypeError("boolean expected for useCapture") + else options = {capture: options} + } else { + options = {capture: false} + } + if (events[type] == null) events[type] = {handlers: [handler], options: [options]} + else { + var found = false + for (var i = 0; i < events[type].handlers.length; i++) { + if (events[type].handlers[i] === handler && events[type].options[i].capture === options.capture) { + found = true + break + } + } + if (!found) { + events[type].handlers.push(handler) + events[type].options.push(options) + } + } }, - removeEventListener: function(type, callback) { + removeEventListener: function(type, handler, options) { + if (arguments.length > 2) { + if (typeof options === "object" && options != null) throw new TypeError("NYI: addEventListener options") + else if (typeof options !== "boolean") throw new TypeError("boolean expected for useCapture") + else options = {capture: options} + } else { + options = {capture: false} + } if (events[type] != null) { - var index = events[type].indexOf(callback) - if (index > -1) events[type].splice(index, 1) + for (var i = 0; i < events[type].handlers.length; i++) { + if (events[type].handlers[i] === handler && events[type].options[i].capture === options.capture) { + events[type].handlers.splice(i, 1) + events[type].options.splice(i, 1) + break; + } + } } }, dispatchEvent: function(e) { - if (this.nodeName === "INPUT" && this.attributes["type"] != null && this.attributes["type"].value === "checkbox" && e.type === "click") { - this.checked = !this.checked + var parents = [] + if (this.parentNode != null) { + var parent = this.parentNode + do { + parents.push(parent) + parent = parent.parentNode + } while (parent != null) } - e.target = this - if (events[e.type] != null) { - for (var i = 0; i < events[e.type].length; i++) { - var handler = events[e.type][i] - if (typeof handler === "function") handler.call(this, e) - else handler.handleEvent(e) + var prevented = false + e.preventDefault = function() { + prevented = true + } + var stopped = false + e.stopPropagation = function() { + stopped = true + } + e.eventPhase = 1 + try { + for (var i = parents.length - 1; 0 <= i; i--) { + dispatchEvent.call(parents[i], e) + if (stopped) { + return + } + } + e.eventPhase = 2 + dispatchEvent.call(this, e) + if (stopped) { + return + } + e.eventPhase = 3 + for (var i = 0; i < parents.length; i++) { + dispatchEvent.call(parents[i], e) + if (stopped) { + return + } + } + } catch(e) { + throw e + } finally { + e.eventPhase = 0 + if (!prevented) { + if (this.nodeName === "INPUT" && this.attributes["type"] != null && this.attributes["type"].value === "checkbox" && e.type === "click") { + this.checked = !this.checked + } } } - e.preventDefault = function() { - // TODO: should this do something? - } - if (typeof this["on" + e.type] === "function" && !isModernEvent(e.type)) this["on" + e.type](e) + }, onclick: null, + _events: events } if (element.nodeName === "A") { @@ -515,7 +603,8 @@ module.exports = function(options) { }, createEvent: function() { return { - initEvent: function(type) {this.type = type}, + eventPhase: 0, + initEvent: function(type) {this.type = type} } }, get activeElement() {return activeElement}, diff --git a/test-utils/tests/test-domMock.js b/test-utils/tests/test-domMock.js index 3e4a9567..f05d24c9 100644 --- a/test-utils/tests/test-domMock.js +++ b/test-utils/tests/test-domMock.js @@ -611,13 +611,57 @@ o.spec("domMock", function() { o(spy.args[0].type).equals("click") o(spy.args[0].target).equals(div) }) - o("removeEventListener works", function(done) { + o("removeEventListener works (bubbling phase)", function() { div.addEventListener("click", spy, false) div.removeEventListener("click", spy, false) div.dispatchEvent(e) o(spy.callCount).equals(0) - done() + }) + o("removeEventListener works (capture phase)", function() { + div.addEventListener("click", spy, true) + div.removeEventListener("click", spy, true) + div.dispatchEvent(e) + + o(spy.callCount).equals(0) + }) + o("removeEventListener is selective (bubbling phase)", function() { + var other = o.spy() + div.addEventListener("click", spy, false) + div.addEventListener("click", other, false) + div.removeEventListener("click", spy, false) + div.dispatchEvent(e) + + o(spy.callCount).equals(0) + o(other.callCount).equals(1) + }) + o("removeEventListener is selective (capture phase)", function() { + var other = o.spy() + div.addEventListener("click", spy, true) + div.addEventListener("click", other, true) + div.removeEventListener("click", spy, true) + div.dispatchEvent(e) + + o(spy.callCount).equals(0) + o(other.callCount).equals(1) + }) + o("removeEventListener only removes the handler related to a given phase (1/2)", function() { + spy = o.spy(function(e) {o(e.eventPhase).equals(3)}) + $document.body.addEventListener("click", spy, true) + $document.body.addEventListener("click", spy, false) + $document.body.removeEventListener("click", spy, true) + div.dispatchEvent(e) + + o(spy.callCount).equals(1) + }) + o("removeEventListener only removes the handler related to a given phase (2/2)", function() { + spy = o.spy(function(e) {o(e.eventPhase).equals(1)}) + $document.body.addEventListener("click", spy, true) + $document.body.addEventListener("click", spy, false) + $document.body.removeEventListener("click", spy, false) + div.dispatchEvent(e) + + o(spy.callCount).equals(1) }) o("click fires onclick", function() { div.onclick = spy @@ -655,6 +699,488 @@ o.spec("domMock", function() { done() }) }) + o.spec("capture and bubbling phases", function() { + var div, e + o.beforeEach(function() { + div = $document.createElement("div") + e = $document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + $document.body.appendChild(div) + }) + o.afterEach(function() { + $document.body.removeChild(div) + }) + o("capture and bubbling events both fire on the target in the order they were defined, regardless of the phase", function () { + var sequence = [] + var capture = o.spy(function(ev){ + sequence.push("capture") + + o(ev).equals(e) + o(ev.eventPhase).equals(2) + o(ev.target).equals(div) + o(ev.currentTarget).equals(div) + }) + var bubble = o.spy(function(ev){ + sequence.push("bubble") + + o(ev).equals(e) + o(ev.eventPhase).equals(2) + o(ev.target).equals(div) + o(ev.currentTarget).equals(div) + }) + + div.addEventListener("click", bubble, false) + div.addEventListener("click", capture, true) + div.dispatchEvent(e) + + o(capture.callCount).equals(1) + o(bubble.callCount).equals(1) + o(sequence).deepEquals(["bubble", "capture"]) + }) + o("capture and bubbling events both fire on the parent", function () { + var sequence = [] + var capture = o.spy(function(ev){ + sequence.push("capture") + + o(ev).equals(e) + o(ev.eventPhase).equals(1) + o(ev.target).equals(div) + o(ev.currentTarget).equals($document.body) + }) + var bubble = o.spy(function(ev){ + sequence.push("bubble") + + o(ev).equals(e) + o(ev.eventPhase).equals(3) + o(ev.target).equals(div) + o(ev.currentTarget).equals($document.body) + }) + + $document.body.addEventListener("click", bubble, false) + $document.body.addEventListener("click", capture, true) + div.dispatchEvent(e) + + o(capture.callCount).equals(1) + o(bubble.callCount).equals(1) + o(sequence).deepEquals(["capture", "bubble"]) + }) + o("useCapture defaults to false", function () { + var sequence = [] + var parent = o.spy(function(ev){ + sequence.push("parent") + + o(ev).equals(e) + o(ev.eventPhase).equals(3) + o(ev.target).equals(div) + o(ev.currentTarget).equals($document.body) + }) + var target = o.spy(function(ev){ + sequence.push("target") + + o(ev).equals(e) + o(ev.eventPhase).equals(2) + o(ev.target).equals(div) + o(ev.currentTarget).equals(div) + }) + + $document.body.addEventListener("click", parent) + div.addEventListener("click", target) + div.dispatchEvent(e) + + o(parent.callCount).equals(1) + o(target.callCount).equals(1) + o(sequence).deepEquals(["target", "parent"]) + }) + o("legacy handlers fire on the bubbling phase", function () { + var sequence = [] + var parent = o.spy(function(ev){ + sequence.push("parent") + + o(ev).equals(e) + o(ev.eventPhase).equals(3) + o(ev.target).equals(div) + o(ev.currentTarget).equals($document.body) + }) + var target = o.spy(function(ev){ + sequence.push("target") + + o(ev).equals(e) + o(ev.eventPhase).equals(2) + o(ev.target).equals(div) + o(ev.currentTarget).equals(div) + }) + + $document.body.addEventListener("click", parent) + $document.body.onclick = parent + div.addEventListener("click", target) + div.dispatchEvent(e) + + o(parent.callCount).equals(2) + o(target.callCount).equals(1) + o(sequence).deepEquals(["target", "parent", "parent"]) + }) + o("events do not propagate to child nodes", function() { + var target = o.spy(function(ev){ + o(ev).equals(e) + o(ev.eventPhase).equals(2) + o(ev.target).equals($document.body) + o(ev.currentTarget).equals($document.body) + }) + var child = o.spy(function(){ + }) + + $document.body.addEventListener("click", target) + div.addEventListener("click", child) + $document.body.dispatchEvent(e) + + o(target.callCount).equals(1) + o(child.callCount).equals(0) + }) + o("e.stopPropagation 1/6", function () { + var capParent = o.spy(function(e){e.stopPropagation()}) + var capTarget = o.spy() + var bubTarget = o.spy() + var legacyTarget = o.spy() + var bubParent = o.spy() + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(0) + o(bubTarget.callCount).equals(0) + o(legacyTarget.callCount).equals(0) + o(bubParent.callCount).equals(0) + o(legacyParent.callCount).equals(0) + }) + o("e.stopPropagation 2/6", function () { + var capParent = o.spy() + var capTarget = o.spy(function(e){e.stopPropagation()}) + var bubTarget = o.spy() + var legacyTarget = o.spy() + var bubParent = o.spy() + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(1) + o(bubParent.callCount).equals(0) + o(legacyParent.callCount).equals(0) + }) + + o("e.stopPropagation 3/6", function () { + var capParent = o.spy() + var capTarget = o.spy() + var bubTarget = o.spy(function(e){e.stopPropagation()}) + var legacyTarget = o.spy() + var bubParent = o.spy() + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(1) + o(bubParent.callCount).equals(0) + o(legacyParent.callCount).equals(0) + }) + o("e.stopPropagation 4/6", function () { + var capParent = o.spy() + var capTarget = o.spy() + var bubTarget = o.spy() + var legacyTarget = o.spy(function(e){e.stopPropagation()}) + var bubParent = o.spy() + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(1) + o(bubParent.callCount).equals(0) + o(legacyParent.callCount).equals(0) + }) + o("e.stopPropagation 5/6", function () { + var capParent = o.spy() + var capTarget = o.spy() + var bubTarget = o.spy() + var legacyTarget = o.spy() + var bubParent = o.spy(function(e){e.stopPropagation()}) + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(1) + o(bubParent.callCount).equals(1) + o(legacyParent.callCount).equals(1) + }) + o("e.stopPropagation 6/6", function () { + var capParent = o.spy() + var capTarget = o.spy() + var legacyTarget = o.spy() + var bubTarget = o.spy() + var bubParent = o.spy() + var legacyParent = o.spy(function(e){e.stopPropagation()}) + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(1) + o(bubParent.callCount).equals(1) + o(legacyParent.callCount).equals(1) + }) + o("e.stopImmediatePropagation 1/6", function () { + var capParent = o.spy(function(e){e.stopImmediatePropagation()}) + var capTarget = o.spy() + var bubTarget = o.spy() + var legacyTarget = o.spy() + var bubParent = o.spy() + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(0) + o(bubTarget.callCount).equals(0) + o(legacyTarget.callCount).equals(0) + o(bubParent.callCount).equals(0) + o(legacyParent.callCount).equals(0) + }) + o("e.stopImmediatePropagation 2/6", function () { + var capParent = o.spy() + var capTarget = o.spy(function(e){e.stopImmediatePropagation()}) + var bubTarget = o.spy() + var legacyTarget = o.spy() + var bubParent = o.spy() + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(0) + o(legacyTarget.callCount).equals(0) + o(bubParent.callCount).equals(0) + o(legacyParent.callCount).equals(0) + }) + + o("e.stopImmediatePropagation 3/6", function () { + var capParent = o.spy() + var capTarget = o.spy() + var bubTarget = o.spy(function(e){e.stopImmediatePropagation()}) + var legacyTarget = o.spy() + var bubParent = o.spy() + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(0) + o(bubParent.callCount).equals(0) + o(legacyParent.callCount).equals(0) + }) + o("e.stopImmediatePropagation 4/6", function () { + var capParent = o.spy() + var capTarget = o.spy() + var bubTarget = o.spy() + var legacyTarget = o.spy(function(e){e.stopImmediatePropagation()}) + var bubParent = o.spy() + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(1) + o(bubParent.callCount).equals(0) + o(legacyParent.callCount).equals(0) + }) + o("e.stopImmediatePropagation 5/6", function () { + var capParent = o.spy() + var capTarget = o.spy() + var bubTarget = o.spy() + var legacyTarget = o.spy() + var bubParent = o.spy(function(e){e.stopImmediatePropagation()}) + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(1) + o(bubParent.callCount).equals(1) + o(legacyParent.callCount).equals(0) + }) + o("e.stopImmediatePropagation 6/6", function () { + var capParent = o.spy() + var capTarget = o.spy() + var legacyTarget = o.spy() + var bubTarget = o.spy() + var bubParent = o.spy() + var legacyParent = o.spy(function(e){e.stopImmediatePropagation()}) + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(1) + o(bubParent.callCount).equals(1) + o(legacyParent.callCount).equals(1) + }) + o("errors thrown in handlers don't interrupt the chain", function(done) { + var errMsg = "The presence of these six errors in the log is expected in non-Node.js environments" + var handler = o.spy(function(){throw errMsg}) + + $document.body.addEventListener("click", handler, true) + $document.body.addEventListener("click", handler, false) + $document.body.onclick = handler + + div.addEventListener("click", handler, true) + div.addEventListener("click", handler, false) + div.onclick = handler + + div.dispatchEvent(e) + + o(handler.callCount).equals(6) + + // Swallow the async errors in NodeJS + if (typeof process !== "undefined" && typeof process.once === "function"){ + process.once("uncaughtException", function(e) { + if (e !== errMsg) throw e + process.once("uncaughtException", function(e) { + if (e !== errMsg) throw e + process.once("uncaughtException", function(e) { + if (e !== errMsg) throw e + process.once("uncaughtException", function(e) { + if (e !== errMsg) throw e + process.once("uncaughtException", function(e) { + if (e !== errMsg) throw e + process.once("uncaughtException", function(e) { + if (e !== errMsg) throw e + done() + }) + }) + }) + }) + }) + }) + } else { + done() + } + }) + }) }) o.spec("attributes", function() { o.spec("a[href]", function() { @@ -731,6 +1257,18 @@ o.spec("domMock", function() { o(input.checked).equals(true) }) + o("doesn't toggle on click when preventDefault() is used", function() { + var input = $document.createElement("input") + input.setAttribute("type", "checkbox") + input.checked = false + input.onclick = function(e) {e.preventDefault()} + + var e = $document.createEvent("MouseEvents") + e.initEvent("click", true, true) + input.dispatchEvent(e) + + o(input.checked).equals(false) + }) }) o.spec("input[value]", function() { o("only exists in input elements", function() {