diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..deef3397 --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +// This is solely to avoid https://github.com/danger/danger-js/issues/261 +// and should be fine to remove before much longer +{} diff --git a/.eslintignore b/.eslintignore index 61d2a0b7..62117a94 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,7 +1,7 @@ -.vscode -/coverage -/docs/lib -/examples +.vscode/ +coverage/ +docs/lib/ +examples/ /mithril.js /mithril.min.js -/node_modules +node_modules/ diff --git a/.gitattributes b/.gitattributes index 6de5c647..7296866f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,5 @@ * text=auto /mithril.js binary /mithril.min.js binary +/package-lock.json binary +/yarn.lock binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..30c39524 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,11 @@ +# Leo owns everything, for better or worse +* @lhorie + +.travis.yml @tivac +package.json @tivac +.npmignore @tivac +.eslintrc.js @tivac +.eslintignore @tivac +README.md @tivac +docs/ @tivac +performance/ @tivac diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..be4f8168 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,32 @@ + + +## Expected Behavior + + + +## Current Behavior + + + +## Possible Solution + + + +## Steps to Reproduce (for bugs) + + +1. +2. +3. +4. + +## Context + + + +## Your Environment + +* Version used: +* Browser Name and version: +* Operating System and version (desktop or mobile): +* Link to your project: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..b60b2e54 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,30 @@ + + +## Description + + +## Motivation and Context + + + +## How Has This Been Tested? + + + + +## Types of changes + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) + +## Checklist: + + +- [ ] My code follows the code style of this project. +- [ ] My change requires a change to the documentation. +- [ ] I have updated the documentation accordingly. +- [ ] I have read the **CONTRIBUTING** document. +- [ ] I have added tests to cover my changes. +- [ ] All new and existing tests passed. +- [ ] I have updated `docs/change-log.md` diff --git a/.travis.yml b/.travis.yml index 75e9b45c..d4470ebd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,13 +13,20 @@ cache: # Custom install step so the travis-only stuff doesn't need to be in package.json install: - npm install -- npm install @alrra/travis-scripts@^3.0.1 gh-pages@^0.12.0 +# This is to prevent lint-staged/prettier from running on the bundles +- npm rm husky -# Bundle before running tests so the bundle is always up-to-date -before_script: npm run build +# Run danger, build bundles (so they're always up to date) +before_script: +- npx danger run +- npm run build-browser +# Pass -save so it'll update the readme as well +- npm run build-min -- -save -# This is the default, but leaving so it is obvious -# script: npm test +# Run tests, lint, and then check for perf regressions +script: +- npm test +- npm run perf # After a successful build commit changes back to repo after_success: @@ -42,7 +49,7 @@ after_success: # this doesn't have the built-in branch protection like commit-changes if [ "$TRAVIS_EVENT_TYPE" == "push" ] && \ [ "$TRAVIS_BRANCH" == "master" ] && \ - [ "$TRAVIS_REPO_SLUG" == "lhorie/mithril.js" ] + [ "$TRAVIS_REPO_SLUG" == "MithrilJS/mithril.js" ] then # Generate docs npm run gendocs @@ -57,7 +64,7 @@ after_success: $(npm bin)/gh-pages \ --dist ./dist \ --add \ - --repo "git@github.com:lhorie/mithril.js.git" \ + --repo "git@github.com:MithrilJS/mithril.js.git" \ --message "Generated docs for commit $TRAVIS_COMMIT [skip ci]" else echo "Not submitting documentation updates" @@ -81,8 +88,7 @@ deploy: skip_cleanup: true on: tags: true - repo: lhorie/mithril.js - branch: master + repo: MithrilJS/mithril.js - provider: npm skip_cleanup: true @@ -91,5 +97,4 @@ deploy: secure: ADElvD1oxn9GfEG7dDOggX96b36A/cGEybovAc0221CCKzv5kWCavMrtxneiJYI6N/n24abSlbM90vMfU84FEzH0Ev28dGVokRP4ad6VRkISszKlYVEP8Lds4QxfKh78jZlUxmxM0B3vmQ1kYJbTBqp3ICtaJ5ptEQHWhrLtxnc= on: tags: true - repo: lhorie/mithril.js - branch: master + repo: MithrilJS/mithril.js diff --git a/README.md b/README.md index 08636be2..4638fa31 100644 --- a/README.md +++ b/README.md @@ -1,267 +1,61 @@ -# Introduction +mithril.js [![NPM Version](https://img.shields.io/npm/v/mithril.svg)](https://www.npmjs.com/package/mithril) [![NPM License](https://img.shields.io/npm/l/mithril.svg)](https://www.npmjs.com/package/mithril) [![NPM Downloads](https://img.shields.io/npm/dm/mithril.svg)](https://www.npmjs.com/package/mithril) +========== + +

+ + Build Status + + + Gitter + +

- [What is Mithril?](#what-is-mithril) -- [Getting started](#getting-started) -- [Hello world](#hello-world) -- [DOM elements](#dom-elements) -- [Components](#components) -- [Routing](#routing) -- [XHR](#xhr) +- [Installation](#installation) +- [Documentation](#documentation) +- [Getting Help](#getting-help) +- [Contributing](#contributing) + +## What is Mithril? + +A modern client-side Javascript framework for building Single Page Applications. It's small (8.22 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 👍. + +Browsers all the way back to IE9 are supported, no polyfills required 👌. + +## Installation + +### CDN + +```html + +``` + +### npm + +```bash +$ npm install mithril +``` + +The ["Getting started" guide](https://mithril.js.org/#getting-started) is a good place to start learning how to use mithril. + +## Documentation + +Documentation lives on [mithril.js.org](https://mithril.js.org). + +You may be interested in the [API Docs](https://mithril.js.org/api.html), a [Simple Application](https://mithril.js.org/simple-application.html), or perhaps some [Examples](https://mithril.js.org/examples.html). + +## 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. + +## Contributing + +There's a [Contributing FAQ](https://mithril.js.org/contributing.html) on the mithril site that hopefully helps, but if not definitely hop into the [Gitter Room](https://gitter.im/mithriljs/mithril.js) and ask away! --- -### What is Mithril? +Thanks for reading! -Mithril is a modern client-side Javascript framework for building Single Page Applications. -It's small (< 8kb gzip), fast and provides routing and XHR utilities out of the box. - -
-
-
Download size
- Mithril (8kb) -
- Vue + Vue-Router + Vuex + fetch (40kb) -
- React + React-Router + Redux + fetch (64kb) -
- Angular (135kb) -
-
-
-
Performance
- Mithril (6.4ms) -
- Vue (9.8ms) -
- React (12.1ms) -
- Angular (11.5ms) -
-
-
- -Mithril is used by companies like Vimeo and Nike, and open source platforms like Lichess. - -If you are an experienced developer and want to know how Mithril compares to other frameworks, see the [framework comparison](http://mithril.js.org/framework-comparison.html) page. - -Mithril supports browsers all the way back to IE9, no polyfills required. - ---- - -### Getting started - -The easiest way to try out Mithril is to include it from a CDN, and follow this tutorial. It'll cover the majority of the API surface (including routing and XHR) but it'll only take 10 minutes. - -Let's create an HTML file to follow along: - -```markup - - - - -``` - ---- - -### Hello world - -Let's start as small as we can: render some text on screen. Copy the code below into your file (and by copy, I mean type it out - you'll learn better) - -```javascript -var root = document.body - -m.render(root, "Hello world") -``` - -Now, let's change the text to something else. Add this line of code under the previous one: - -```javascript -m.render(root, "My first app") -``` - -As you can see, you use the same code to both create and update HTML. Mithril automatically figures out the most efficient way of updating the text, rather than blindly recreating it from scratch. - ---- - -### DOM elements - -Let's wrap our text in an `

` tag. - -```javascript -m.render(root, m("h1", "My first app")) -``` - -The `m()` function can be used to describe any HTML structure you want. So if you need to add a class to the `

`: - -```javascript -m("h1", {class: "title"}, "My first app") -``` - -If you want to have multiple elements: - -```javascript -[ - m("h1", {class: "title"}, "My first app"), - m("button", "A button"), -] -``` - -And so on: - -```javascript -m("main", [ - m("h1", {class: "title"}, "My first app"), - m("button", "A button"), -]) -``` - -Note: If you prefer `` syntax, [it's possible to use it via a Babel plugin](http://mithril.js.org/jsx.html). - -```jsx -// HTML syntax via Babel's JSX plugin -
-

My first app

- -
-``` - ---- - -### Components - -A Mithril component is just an object with a `view` function. Here's the code above as a component: - -```javascript -var Hello = { - view: function() { - return m("main", [ - m("h1", {class: "title"}, "My first app"), - m("button", "A button"), - ]) - } -} -``` - -To activate the component, we use `m.mount`. - -```javascript -m.mount(root, Hello) -``` - -As you would expect, doing so creates this markup: - -```markup -
-

My first app

- -
-``` - -The `m.mount` function is similar to `m.render`, but instead of rendering some HTML only once, it activates Mithril's auto-redrawing system. To understand what that means, let's add some events: - -```javascript -var count = 0 // added a variable - -var Hello = { - view: function() { - return m("main", [ - m("h1", {class: "title"}, "My first app"), - // changed the next line - m("button", {onclick: function() {count++}}, count + " clicks"), - ]) - } -} - -m.mount(root, Hello) -``` - -We defined an `onclick` event on the button, which increments a variable `count` (which was declared at the top). We are now also rendering the value of that variable in the button label. - -You can now update the label of the button by clicking the button. Since we used `m.mount`, you don't need to manually call `m.render` to apply the changes in the `count` variable to the HTML; Mithril does it for you. - -If you're wondering about performance, it turns out Mithril is very fast at rendering updates, because it only touches the parts of the DOM it absolutely needs to. So in our example above, when you click the button, the text in it is the only part of the DOM Mithril actually updates. - ---- - -### Routing - -Routing just means going from one screen to another in an application with several screens. - -Let's add a splash page that appears before our click counter. First we create a component for it: - -```javascript -var Splash = { - view: function() { - return m("a", {href: "#!/hello"}, "Enter!") - } -} -``` - -As you can see, this component simply renders a link to `#!/hello`. The `#!` part is known as a hashbang, and it's a common convention used in Single Page Applications to indicate that the stuff after it (the `/hello` part) is a route path. - -Now that we going to have more than one screen, we use `m.route` instead of `m.mount`. - -```javascript -m.route(root, "/splash", { - "/splash": Splash, - "/hello": Hello, -}) -``` - -The `m.route` function still has the same auto-redrawing functionality that `m.mount` does, and it also enables URL awareness; in other words, it lets Mithril know what to do when it sees a `#!` in the URL. - -The `"/splash"` right after `root` means that's the default route, i.e. if the hashbang in the URL doesn't point to one of the defined routes (`/splash` and `/hello`, in our case), then Mithril redirects to the default route. So if you open the page in a browser and your URL is `http://localhost`, then you get redirected to `http://localhost/#!/splash`. - -Also, as you would expect, clicking on the link on the splash page takes you to the click counter screen we created earlier. Notice that now your URL will point to `http://localhost/#!/hello`. You can navigate back and forth to the splash page using the browser's back and next button. - ---- - -### XHR - -Basically, XHR is just a way to talk to a server. - -Let's change our click counter to make it save data on a server. For the server, we'll use [REM](http://rem-rest-api.herokuapp.com), a mock REST API designed for toy apps like this tutorial. - -First we create a function that calls `m.request`. The `url` specifies an endpoint that represents a resource, the `method` specifies the type of action we're taking (typically the `PUT` method [upserts](https://en.wiktionary.org/wiki/upsert)), `data` is the payload that we're sending to the endpoint and `withCredentials` means to enable cookies (a requirement for the REM API to work) - -```javascript -var count = 0 -var increment = function() { - m.request({ - method: "PUT", - url: "//rem-rest-api.herokuapp.com/api/tutorial/1", - data: {count: count + 1}, - withCredentials: true, - }) - .then(function(data) { - count = parseInt(data.count) - }) -} -``` - -Calling the increment function [upserts](https://en.wiktionary.org/wiki/upsert) an object `{count: 1}` to the `/api/tutorial/1` endpoint. This endpoint returns an object with the same `count` value that was sent to it. Notice that the `count` variable is only updated after the request completes, and it's updated with the response value from the server now. - -Let's replace the event handler in the component to call the `increment` function instead of incrementing the `count` variable directly: - -```javascript -var Hello = { - view: function() { - return m("main", [ - m("h1", {class: "title"}, "My first app"), - m("button", {onclick: increment}, count + " clicks"), - ]) - } -} -``` - -Clicking the button should now update the count. - ---- - -We covered how to create and update HTML, how to create components, routes for a Single Page Application, and interacted with a server via XHR. - -This should be enough to get you started writing the frontend for a real application. Now that you are comfortable with the basics of the Mithril API, [be sure to check out the simple application tutorial](http://mithril.js.org/simple-application.html), which walks you through building a realistic application. +🎁 diff --git a/api/mount.js b/api/mount.js index 2178505a..7203bf7c 100644 --- a/api/mount.js +++ b/api/mount.js @@ -16,6 +16,6 @@ module.exports = function(redrawService) { redrawService.render(root, Vnode(component)) } redrawService.subscribe(root, run) - redrawService.redraw() + run() } } diff --git a/api/redraw.js b/api/redraw.js index 1b22271b..45a86588 100644 --- a/api/redraw.js +++ b/api/redraw.js @@ -4,44 +4,48 @@ var coreRenderer = require("../render/render") function throttle(callback) { //60fps translates to 16.6ms, round it down since setTimeout requires int - var time = 16 + var delay = 16 var last = 0, pending = null var timeout = typeof requestAnimationFrame === "function" ? requestAnimationFrame : setTimeout return function() { - var now = Date.now() - if (last === 0 || now - last >= time) { - last = now - callback() - } - else if (pending === null) { + var elapsed = Date.now() - last + if (pending === null) { pending = timeout(function() { pending = null callback() last = Date.now() - }, time - (now - last)) + }, delay - elapsed) } } } -module.exports = function($window) { + +module.exports = function($window, throttleMock) { var renderService = coreRenderer($window) renderService.setEventCallback(function(e) { - if (e.redraw !== false) redraw() + if (e.redraw === false) e.redraw = undefined + else redraw() }) var callbacks = [] + var rendering = false + function subscribe(key, callback) { unsubscribe(key) - callbacks.push(key, throttle(callback)) + callbacks.push(key, callback) } function unsubscribe(key) { var index = callbacks.indexOf(key) if (index > -1) callbacks.splice(index, 2) } - function redraw() { - for (var i = 1; i < callbacks.length; i += 2) { - callbacks[i]() - } + function sync() { + if (rendering) throw new Error("Nested m.redraw.sync() call") + rendering = true + for (var i = 1; i < callbacks.length; i+=2) try {callbacks[i]()} catch (e) {/*noop*/} + rendering = false } + + var redraw = (throttleMock || throttle)(sync) + redraw.sync = sync return {subscribe: subscribe, unsubscribe: unsubscribe, redraw: redraw, render: renderService.render} } diff --git a/api/router.js b/api/router.js index c182008f..40ccb7cd 100644 --- a/api/router.js +++ b/api/router.js @@ -11,9 +11,14 @@ module.exports = function($window, redrawService) { var render, component, attrs, currentPath, lastUpdate var route = function(root, defaultRoute, routes) { if (root == null) throw new Error("Ensure the DOM element that was passed to `m.route` is not undefined") - var run = function() { + function run() { if (render != null) redrawService.render(root, render(Vnode(component, attrs.key, attrs))) } + var redraw = function() { + run() + redraw = redrawService.redraw + } + redrawService.subscribe(root, run) var bail = function(path) { if (path !== defaultRoute) routeService.setPath(defaultRoute, null, {replace: true}) else throw new Error("Could not resolve default route " + defaultRoute) @@ -24,7 +29,7 @@ module.exports = function($window, redrawService) { component = comp != null && (typeof comp.view === "function" || typeof comp === "function")? comp : "div" attrs = params, currentPath = path, lastUpdate = null render = (routeResolver.render || identity).bind(routeResolver) - run() + redraw() } if (payload.view || typeof payload === "function") update({}, payload) else { @@ -36,10 +41,12 @@ module.exports = function($window, redrawService) { else update(payload, "div") } }, bail) - redrawService.subscribe(root, run) } route.set = function(path, data, options) { - if (lastUpdate != null) options = {replace: true} + if (lastUpdate != null) { + options = options || {} + options.replace = true + } lastUpdate = null routeService.setPath(path, data, options) } diff --git a/api/tests/test-mount.js b/api/tests/test-mount.js index db2bee04..7385e43d 100644 --- a/api/tests/test-mount.js +++ b/api/tests/test-mount.js @@ -3,24 +3,27 @@ var o = require("../../ospec/ospec") var components = require("../../test-utils/components") var domMock = require("../../test-utils/domMock") +var throttleMocker = require("../../test-utils/throttleMock") var m = require("../../render/hyperscript") -var coreRenderer = require("../../render/render") var apiRedraw = require("../../api/redraw") var apiMounter = require("../../api/mount") o.spec("mount", function() { - var FRAME_BUDGET = Math.floor(1000 / 60) - var $window, root, redrawService, mount, render + var $window, root, redrawService, mount, render, throttleMock o.beforeEach(function() { $window = domMock() + throttleMock = throttleMocker() root = $window.document.body - - redrawService = apiRedraw($window) + redrawService = apiRedraw($window, throttleMock.throttle) mount = apiMounter(redrawService) - render = coreRenderer($window).render + render = redrawService.render + }) + + o.afterEach(function() { + o(throttleMock.queueLength()).equals(0) }) o("throws on invalid component", function() { @@ -47,7 +50,7 @@ o.spec("mount", function() { o(threw).equals(true) }) - o("renders into `root`", function() { + o("renders into `root` synchronoulsy", function() { mount(root, createComponent({ view : function() { return m("div") @@ -69,7 +72,37 @@ o.spec("mount", function() { o(root.childNodes.length).equals(0) }) - o("redraws on events", function(done) { + o("Mounting a second root doesn't cause the first one to redraw", function() { + var view = o.spy(function() { + return m("div") + }) + + render(root, [ + m("#child0"), + m("#child1") + ]) + + mount(root.childNodes[0], createComponent({ + view : view + })) + + o(root.firstChild.nodeName).equals("DIV") + o(view.callCount).equals(1) + + mount(root.childNodes[1], createComponent({ + view : function() { + return m("div") + } + })) + + o(view.callCount).equals(1) + + throttleMock.fire() + + o(view.callCount).equals(1) + }) + + o("redraws on events", function() { var onupdate = o.spy() var oninit = o.spy() var onclick = o.spy() @@ -97,17 +130,12 @@ o.spec("mount", function() { o(onclick.args[0].type).equals("click") o(onclick.args[0].target).equals(root.firstChild) - // Wrapped to give time for the rate-limited redraw to fire - setTimeout(function() { - o(onupdate.callCount).equals(1) + throttleMock.fire() - done() - }, FRAME_BUDGET) + o(onupdate.callCount).equals(1) }) - o("redraws several mount points on events", function(done, timeout) { - timeout(60) - + o("redraws several mount points on events", function() { var onupdate0 = o.spy() var oninit0 = o.spy() var onclick0 = o.spy() @@ -154,26 +182,26 @@ o.spec("mount", function() { o(onclick0.callCount).equals(1) o(onclick0.this).equals(root.childNodes[0].firstChild) - setTimeout(function() { - o(onupdate0.callCount).equals(1) - o(onupdate1.callCount).equals(1) + throttleMock.fire() - root.childNodes[1].firstChild.dispatchEvent(e) - o(onclick1.callCount).equals(1) - o(onclick1.this).equals(root.childNodes[1].firstChild) + o(onupdate0.callCount).equals(1) + o(onupdate1.callCount).equals(1) - setTimeout(function() { - o(onupdate0.callCount).equals(2) - o(onupdate1.callCount).equals(2) + root.childNodes[1].firstChild.dispatchEvent(e) - done() - }, FRAME_BUDGET) - }, FRAME_BUDGET) + o(onclick1.callCount).equals(1) + o(onclick1.this).equals(root.childNodes[1].firstChild) + throttleMock.fire() + + o(onupdate0.callCount).equals(2) + o(onupdate1.callCount).equals(2) }) - o("event handlers can skip redraw", function(done) { - var onupdate = o.spy() + o("event handlers can skip redraw", function() { + var onupdate = o.spy(function(){ + throw new Error("This shouldn't have been called") + }) var oninit = o.spy() var e = $window.document.createEvent("MouseEvents") @@ -195,15 +223,12 @@ o.spec("mount", function() { o(oninit.callCount).equals(1) - // Wrapped to ensure no redraw fired - setTimeout(function() { - o(onupdate.callCount).equals(0) + throttleMock.fire() - done() - }, FRAME_BUDGET) + o(onupdate.callCount).equals(0) }) - o("redraws when the render function is run", function(done) { + o("redraws when the render function is run", function() { var onupdate = o.spy() var oninit = o.spy() @@ -221,17 +246,12 @@ o.spec("mount", function() { redrawService.redraw() - // Wrapped to give time for the rate-limited redraw to fire - setTimeout(function() { - o(onupdate.callCount).equals(1) + throttleMock.fire() - done() - }, FRAME_BUDGET) + o(onupdate.callCount).equals(1) }) - o("throttles", function(done, timeout) { - timeout(200) - + o("throttles", function() { var i = 0 mount(root, createComponent({view: function() {i++}})) var before = i @@ -243,12 +263,11 @@ o.spec("mount", function() { var after = i - setTimeout(function(){ - o(before).equals(1) // mounts synchronously - o(after).equals(1) // throttles rest - o(i).equals(2) - done() - },40) + throttleMock.fire() + + o(before).equals(1) // mounts synchronously + o(after).equals(1) // throttles rest + o(i).equals(2) }) }) }) diff --git a/api/tests/test-redraw.js b/api/tests/test-redraw.js index f13c2d3f..65831bb0 100644 --- a/api/tests/test-redraw.js +++ b/api/tests/test-redraw.js @@ -2,6 +2,7 @@ var o = require("../../ospec/ospec") var domMock = require("../../test-utils/domMock") +var throttleMocker = require("../../test-utils/throttleMock") var apiRedraw = require("../../api/redraw") o.spec("redrawService", function() { @@ -17,25 +18,39 @@ o.spec("redrawService", function() { redrawService.redraw() }) + o("honours throttleMock", function() { + var throttleMock = throttleMocker() + redrawService = apiRedraw(domMock(), throttleMock.throttle) + var spy = o.spy() + + redrawService.subscribe(root, spy) + + o(spy.callCount).equals(0) + + redrawService.redraw() + + o(spy.callCount).equals(0) + + throttleMock.fire() + + o(spy.callCount).equals(1) + }) + o("should run a single renderer entry", function(done) { var spy = o.spy() redrawService.subscribe(root, spy) o(spy.callCount).equals(0) - - redrawService.redraw() - - o(spy.callCount).equals(1) redrawService.redraw() redrawService.redraw() redrawService.redraw() - o(spy.callCount).equals(1) + o(spy.callCount).equals(0) setTimeout(function() { - o(spy.callCount).equals(2) - + o(spy.callCount).equals(1) + done() }, 20) }) @@ -54,27 +69,29 @@ o.spec("redrawService", function() { redrawService.redraw() - o(spy1.callCount).equals(1) - o(spy2.callCount).equals(1) - o(spy3.callCount).equals(1) + o(spy1.callCount).equals(0) + o(spy2.callCount).equals(0) + o(spy3.callCount).equals(0) redrawService.redraw() - o(spy1.callCount).equals(1) - o(spy2.callCount).equals(1) - o(spy3.callCount).equals(1) + o(spy1.callCount).equals(0) + o(spy2.callCount).equals(0) + o(spy3.callCount).equals(0) setTimeout(function() { - o(spy1.callCount).equals(2) - o(spy2.callCount).equals(2) - o(spy3.callCount).equals(2) - + o(spy1.callCount).equals(1) + o(spy2.callCount).equals(1) + o(spy3.callCount).equals(1) + done() }, 20) }) - o("should stop running after unsubscribe", function() { - var spy = o.spy() + o("should stop running after unsubscribe", function(done) { + var spy = o.spy(function() { + throw new Error("This shouldn't have been called") + }) redrawService.subscribe(root, spy) redrawService.unsubscribe(root, spy) @@ -82,9 +99,33 @@ o.spec("redrawService", function() { redrawService.redraw() o(spy.callCount).equals(0) + setTimeout(function() { + o(spy.callCount).equals(0) + + done() + }, 20) }) - o("does nothing on invalid unsubscribe", function() { + o("should stop running after unsubscribe, even if it occurs after redraw is requested", function(done) { + var spy = o.spy(function() { + throw new Error("This shouldn't have been called") + }) + + redrawService.subscribe(root, spy) + + redrawService.redraw() + + redrawService.unsubscribe(root, spy) + + o(spy.callCount).equals(0) + setTimeout(function() { + o(spy.callCount).equals(0) + + done() + }, 20) + }) + + o("does nothing on invalid unsubscribe", function(done) { var spy = o.spy() redrawService.subscribe(root, spy) @@ -92,6 +133,39 @@ o.spec("redrawService", function() { redrawService.redraw() - o(spy.callCount).equals(1) + setTimeout(function() { + o(spy.callCount).equals(1) + + done() + }, 20) + }) + + o("redraw.sync() redraws all roots synchronously", function() { + var el1 = $document.createElement("div") + var el2 = $document.createElement("div") + var el3 = $document.createElement("div") + var spy1 = o.spy() + var spy2 = o.spy() + var spy3 = o.spy() + + redrawService.subscribe(el1, spy1) + redrawService.subscribe(el2, spy2) + redrawService.subscribe(el3, spy3) + + o(spy1.callCount).equals(0) + o(spy2.callCount).equals(0) + o(spy3.callCount).equals(0) + + redrawService.redraw.sync() + + o(spy1.callCount).equals(1) + o(spy2.callCount).equals(1) + o(spy3.callCount).equals(1) + + redrawService.redraw.sync() + + o(spy1.callCount).equals(2) + o(spy2.callCount).equals(2) + o(spy3.callCount).equals(2) }) }) diff --git a/api/tests/test-router.js b/api/tests/test-router.js index 9406de70..69aff4ca 100644 --- a/api/tests/test-router.js +++ b/api/tests/test-router.js @@ -3,6 +3,7 @@ var o = require("../../ospec/ospec") var callAsync = require("../../test-utils/callAsync") var browserMock = require("../../test-utils/browserMock") +var throttleMocker = require("../../test-utils/throttleMock") var m = require("../../render/hyperscript") var callAsync = require("../../test-utils/callAsync") @@ -14,19 +15,23 @@ o.spec("route", function() { void [{protocol: "http:", hostname: "localhost"}, {protocol: "file:", hostname: "/"}].forEach(function(env) { void ["#", "?", "", "#!", "?!", "/foo"].forEach(function(prefix) { o.spec("using prefix `" + prefix + "` starting on " + env.protocol + "//" + env.hostname, function() { - var FRAME_BUDGET = Math.floor(1000 / 60) - var $window, root, redrawService, route + var $window, root, redrawService, route, throttleMock o.beforeEach(function() { $window = browserMock(env) + throttleMock = throttleMocker() root = $window.document.body - redrawService = apiRedraw($window) + redrawService = apiRedraw($window, throttleMock.throttle) route = apiRouter($window, redrawService) route.prefix(prefix) }) + o.afterEach(function() { + o(throttleMock.queueLength()).equals(0) + }) + o("throws on invalid `root` DOM node", function() { var threw = false try { @@ -50,7 +55,7 @@ o.spec("route", function() { o(root.firstChild.nodeName).equals("DIV") }) - o("routed mount points can redraw synchronously (POJO component)", function() { + o("routed mount points only redraw asynchronously (POJO component)", function() { var view = o.spy() $window.location.href = prefix + "/" @@ -60,11 +65,14 @@ o.spec("route", function() { redrawService.redraw() - o(view.callCount).equals(2) + o(view.callCount).equals(1) + throttleMock.fire() + + o(view.callCount).equals(2) }) - o("routed mount points can redraw synchronously (constructible component)", function() { + o("routed mount points only redraw asynchronously (constructible component)", function() { var view = o.spy() var Cmp = function(){} @@ -77,11 +85,14 @@ o.spec("route", function() { redrawService.redraw() - o(view.callCount).equals(2) + o(view.callCount).equals(1) + throttleMock.fire() + + o(view.callCount).equals(2) }) - o("routed mount points can redraw synchronously (closure component)", function() { + o("routed mount points only redraw asynchronously (closure component)", function() { var view = o.spy() function Cmp() {return {view: view}} @@ -93,8 +104,11 @@ o.spec("route", function() { redrawService.redraw() - o(view.callCount).equals(2) + o(view.callCount).equals(1) + throttleMock.fire() + + o(view.callCount).equals(2) }) o("default route doesn't break back button", function(done) { @@ -160,11 +174,12 @@ o.spec("route", function() { o(oninit.callCount).equals(1) redrawService.redraw() + throttleMock.fire() o(onupdate.callCount).equals(1) }) - o("redraws on events", function(done) { + o("redraws on events", function() { var onupdate = o.spy() var oninit = o.spy() var onclick = o.spy() @@ -194,12 +209,9 @@ o.spec("route", function() { o(onclick.args[0].type).equals("click") o(onclick.args[0].target).equals(root.firstChild) - // Wrapped to give time for the rate-limited redraw to fire - callAsync(function() { - o(onupdate.callCount).equals(1) - done() - }) + throttleMock.fire() + o(onupdate.callCount).equals(1) }) o("event handlers can skip redraw", function(done) { @@ -224,9 +236,11 @@ o.spec("route", function() { } }) + o(oninit.callCount).equals(1) + root.firstChild.dispatchEvent(e) - o(oninit.callCount).equals(1) + o(e.redraw).notEquals(false) // Wrapped to ensure no redraw fired callAsync(function() { @@ -500,7 +514,10 @@ o.spec("route", function() { o(oninit.callCount).equals(1) route.set("/def") callAsync(function() { + throttleMock.fire() + o(oninit.callCount).equals(2) + done() }) }) @@ -536,23 +553,28 @@ o.spec("route", function() { route(root, "/a", { "/a" : { render: function() { - return m("div") + return m("div", m("p")) }, }, "/b" : { render: function() { - return m("div") + return m("div", m("a")) }, }, }) var dom = root.firstChild + var child = dom.firstChild + o(root.firstChild.nodeName).equals("DIV") route.set("/b") callAsync(function() { + throttleMock.fire() + o(root.firstChild).equals(dom) + o(root.firstChild.firstChild).notEquals(child) done() }) @@ -586,6 +608,7 @@ o.spec("route", function() { o(renderCount).equals(1) redrawService.redraw() + throttleMock.fire() o(matchCount).equals(1) o(renderCount).equals(2) @@ -621,6 +644,7 @@ o.spec("route", function() { o(renderCount).equals(1) redrawService.redraw() + throttleMock.fire() o(matchCount).equals(1) o(renderCount).equals(2) @@ -665,7 +689,7 @@ o.spec("route", function() { route(root, "/a", { "/a" : { onmatch: function() { - route.set("/b") + route.set("/b", {}, {state: {a: 5}}) }, render: render }, @@ -684,6 +708,7 @@ o.spec("route", function() { o(view.callCount).equals(1) o(root.childNodes.length).equals(1) o(root.firstChild.nodeName).equals("DIV") + o($window.history.state).deepEquals({a: 5}) done() }) @@ -815,10 +840,14 @@ o.spec("route", function() { }) callAsync(function() { + throttleMock.fire() + route.set("/b") callAsync(function() { callAsync(function() { callAsync(function() { + throttleMock.fire() + o(render.callCount).equals(0) o(component.view.callCount).equals(2) @@ -939,6 +968,7 @@ o.spec("route", function() { o(onmatch.callCount).equals(1) redrawService.redraw() + throttleMock.fire() o(view.callCount).equals(2) o(onmatch.callCount).equals(1) @@ -1017,6 +1047,8 @@ o.spec("route", function() { }) callAsync(function() { + throttleMock.fire() + o(onmatch.callCount).equals(1) o(render.callCount).equals(1) @@ -1024,6 +1056,8 @@ o.spec("route", function() { callAsync(function() { callAsync(function() { + throttleMock.fire() + o(onmatch.callCount).equals(2) o(render.callCount).equals(2) @@ -1074,9 +1108,15 @@ o.spec("route", function() { route.set("/b") callAsync(function() { + throttleMock.fire() + + o(root.firstChild.nodeName).equals("B") + route.set("/a") callAsync(function() { + throttleMock.fire() + o(root.firstChild.nodeName).equals("A") done() @@ -1141,7 +1181,9 @@ o.spec("route", function() { route.set("/b") + // setting the route is asynchronous callAsync(function() { + throttleMock.fire() o(spy.callCount).equals(1) done() @@ -1181,9 +1223,7 @@ o.spec("route", function() { }) }) - o("throttles", function(done, timeout) { - timeout(200) - + o("throttles", function() { var i = 0 $window.location.href = prefix + "/" route(root, "/", { @@ -1197,12 +1237,11 @@ o.spec("route", function() { redrawService.redraw() var after = i - setTimeout(function() { - o(before).equals(1) // routes synchronously - o(after).equals(2) // redraws synchronously - o(i).equals(3) // throttles rest - done() - }, FRAME_BUDGET * 2) + throttleMock.fire() + + o(before).equals(1) // routes synchronously + o(after).equals(1) // redraws asynchronously + o(i).equals(2) }) o("m.route.param is available outside of route handlers", function(done) { diff --git a/bundler/cli.js b/bundler/cli.js index f702ce7e..93ad0adf 100644 --- a/bundler/cli.js +++ b/bundler/cli.js @@ -5,7 +5,7 @@ var fs = require("fs"); var bundle = require("./bundle") var minify = require("./minify") -var aliases = {o: "output", m: "minify", w: "watch", a: "aggressive"} +var aliases = {o: "output", m: "minify", w: "watch", a: "aggressive", s: "save"} var params = {} var args = process.argv.slice(2), command = null for (var i = 0; i < args.length; i++) { @@ -27,8 +27,6 @@ function add(value) { bundle(params.input, params.output, {watch: params.watch}) if (params.minify) { minify(params.output, params.output, {watch: params.watch, advanced: params.aggressive}, function(stats) { - var readme, kb; - function format(n) { return n.toString().replace(/(\d)(?=(\d\d\d)+(?!\d))/g, "$1,") } @@ -36,14 +34,16 @@ if (params.minify) { console.log("Original size: " + format(stats.originalGzipSize) + " bytes gzipped (" + format(stats.originalSize) + " bytes uncompressed)") console.log("Compiled size: " + format(stats.compressedGzipSize) + " bytes gzipped (" + format(stats.compressedSize) + " bytes uncompressed)") - readme = fs.readFileSync("./README.md", "utf8") - kb = stats.compressedGzipSize / 1024 + if (params.save) { + var readme = fs.readFileSync("./README.md", "utf8") + var kb = stats.compressedGzipSize / 1000 - fs.writeFileSync("./README.md", - readme.replace( - /()(.+?)()/, - "$1" + (kb % 1 ? kb.toFixed(2) : kb) + " KB$3" + fs.writeFileSync("./README.md", + readme.replace( + /()(.+?)()/, + "$1" + (kb % 1 ? kb.toFixed(2) : kb) + " KB$3" + ) ) - ) + } }) } diff --git a/dangerfile.js b/dangerfile.js new file mode 100644 index 00000000..89ea4277 --- /dev/null +++ b/dangerfile.js @@ -0,0 +1,58 @@ +/* global danger warn fail */ +"use strict"; + +var fs = require("fs"), + path = require("path"), + + locater = require("locater"), + pinpoint = require("pinpoint"), + dedent = require("dedent"); + +// Various views of changed/added files +var jsfiles = danger.git.created_files + .concat(danger.git.modified_files) + .filter((file) => path.extname(file) === ".js"), + + changelog = danger.git.modified_files.find((file) => + file === "docs/change-log.md" + ), + + appfiles = jsfiles.filter((file) => + file.indexOf("tests/") === -1 + ); + +function link(file, anchor, text) { + var repo = danger.github.pr.head.repo.html_url, + ref = danger.github.pr.head.ref; + + return danger.utils.href(`${repo}/blob/${ref}/${file}${anchor || ""}`, file || text); +} + +// All PRs should be targeted against `next` +if(danger.github.pr.base.ref !== "next") { + warn("PRs should be based on `next`, rebase before submitting please"); +} + +// Any non-test JS changes should probably have a change-log entry +if(appfiles.length && !changelog) { + warn(dedent(` + Please add an entry to ${link("docs/change-log.md")}. + `)) +} + +// Call out if `o.only(...)` was left in +jsfiles + .filter((file) => file.indexOf("tests/") > -1) + .forEach((file) => { + var code = fs.readFileSync(file, "utf8"), + locs = locater.find("o.only", code); + + locs.forEach((loc) => + fail(dedent(` + Please remove the \`o.only\` from ${link(file, `#L${loc.line}`)}. +
+				${pinpoint(code, {line: loc.line, column : loc.cursor})}
+				
+ `)) + ) + }); diff --git a/docs/change-log.md b/docs/change-log.md index 2d3b03fe..cb2fabf0 100644 --- a/docs/change-log.md +++ b/docs/change-log.md @@ -1,5 +1,7 @@ # Change log +- [v1.1.3](#v113) +- [v1.1.2](#v112) - [v1.1.1](#v111) - [v1.1.0](#v110) - [v1.0.1](#v101) @@ -8,14 +10,69 @@ --- +### v2.0.0 (WIP) + +#### Breaking changes + +- 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)) + +#### News + +- API: Introduction of `m.redraw.sync()` ([#1592](https://github.com/MithrilJS/mithril.js/pull/1592)) + +#### Bug fixes + +- API: `m.route.set()` causes all mount points to be redrawn ([#1592](https://github.com/MithrilJS/mithril.js/pull/1592)) + +--- + +### v1.1.3 + +#### Bug fixes: + +- move out npm dependencies added by mistake + +--- + +### v1.1.2 + +#### Bug fixes: + +- core: Namespace fixes [#1819](https://github.com/MithrilJS/mithril.js/issues/1819), ([#1825](https://github.com/MithrilJS/mithril.js/pull/1825) [@SamuelTilly](https://github.com/SamuelTilly)), [#1820](https://github.com/MithrilJS/mithril.js/issues/1820) ([#1864](https://github.com/MithrilJS/mithril.js/pull/1864)), [#1872](https://github.com/MithrilJS/mithril.js/issues/1872) ([#1873](https://github.com/MithrilJS/mithril.js/pull/1873)) +- core: Fix select option to allow empty string value [#1814](https://github.com/MithrilJS/mithril.js/issues/1814) ([#1828](https://github.com/MithrilJS/mithril.js/pull/1828) [@spacejack](https://github.com/spacejack)) +- core: Reset e.redraw when it was set to `false` [#1850](https://github.com/MithrilJS/mithril.js/issues/1850) ([#1890](https://github.com/MithrilJS/mithril.js/pull/1890)) +- core: differentiate between `{ value: "" }` and `{ value: 0 }` for form elements [#1595 comment](https://github.com/MithrilJS/mithril.js/pull/1595#issuecomment-304071453) ([#1862](https://github.com/MithrilJS/mithril.js/pull/1862)) +- core: Don't reset the cursor of textareas in IE10 when setting an identical `value` [#1870](https://github.com/MithrilJS/mithril.js/issues/1870) ([#1871](https://github.com/MithrilJS/mithril.js/pull/1871)) +- hypertext: Correct handling of `[value=""]` ([#1843](https://github.com/MithrilJS/mithril.js/issues/1843), [@CreaturesInUnitards](https://github.com/CreaturesInUnitards)) +- router: Don't overwrite the options object when redirecting from `onmatch with m.route.set()` [#1857](https://github.com/MithrilJS/mithril.js/issues/1857) ([#1889](https://github.com/MithrilJS/mithril.js/pull/1889)) +- stream: Move the "use strict" directive inside the IIFE [#1831](https://github.com/MithrilJS/mithril.js/issues/1831) ([#1893](https://github.com/MithrilJS/mithril.js/pull/1893)) + +#### Ospec improvements: + +- Shell command: Ignore hidden directories and files ([#1855](https://github.com/MithrilJS/mithril.js/pull/1855) [@pdfernhout)](https://github.com/pdfernhout)) +- Library: Add the possibility to name new test suites ([#1529](https://github.com/MithrilJS/mithril.js/pull/1529)) + +#### Docs / Repo maintenance: + +Our thanks to [@0joshuaolson1](https://github.com/0joshuaolson1), [@ACXgit](https://github.com/ACXgit), [@cavemansspa](https://github.com/cavemansspa), [@CreaturesInUnitards](https://github.com/CreaturesInUnitards), [@dlepaux](https://github.com/dlepaux), [@isaaclyman](https://github.com/isaaclyman), [@kevinkace](https://github.com/kevinkace), [@micellius](https://github.com/micellius), [@spacejack](https://github.com/spacejack) and [@yurivish](https://github.com/yurivish) + +#### Other: + +- Addition of a performance regression test suite ([#1789](https://github.com/MithrilJS/mithril.js/issues/1789)) + +--- + ### v1.1.1 #### Bug fixes -- hyperscript: Allow `0` as the second argument to `m()` - [#1752](https://github.com/lhorie/mithril.js/issues/#1752) / [#1753](https://github.com/lhorie/mithril.js/pull/#1753) ([@StephanHoyer](https://github.com/StephanHoyer)) -- hyperscript: remove `attrs.class` after normalizing to `attrs.className` - [#1764](https://github.com/lhorie/mithril.js/issues/#1764) / [#1769](https://github.com/lhorie/mithril.js/pull/#1769) +- hyperscript: Allow `0` as the second argument to `m()` - [#1752](https://github.com/MithrilJS/mithril.js/issues/1752) / [#1753](https://github.com/MithrilJS/mithril.js/pull/1753) ([@StephanHoyer](https://github.com/StephanHoyer)) +- hyperscript: restore `attrs.class` handling to what it was in v1.0.1 - [#1764](https://github.com/MithrilJS/mithril.js/issues/1764) / [#1769](https://github.com/MithrilJS/mithril.js/pull/1769) - documentation improvements ([@JAForbes](https://github.com/JAForbes), [@smuemd](https://github.com/smuemd), [@hankeypancake](https://github.com/hankeypancake)) +--- + ### v1.1.0 #### News @@ -26,10 +83,10 @@ #### Bug fixes -- fix IE11 input[type] error - [#1610](https://github.com/lhorie/mithril.js/issues/1610) -- apply [#1609](https://github.com/lhorie/mithril.js/issues/1609) to unkeyed children case -- fix abort detection [#1612](https://github.com/lhorie/mithril.js/issues/1612) -- fix input value focus issue when value is loosely equal to old value [#1593](https://github.com/lhorie/mithril.js/issues/1593) +- fix IE11 input[type] error - [#1610](https://github.com/MithrilJS/mithril.js/issues/1610) +- apply [#1609](https://github.com/MithrilJS/mithril.js/issues/1609) to unkeyed children case +- fix abort detection [#1612](https://github.com/MithrilJS/mithril.js/issues/1612) +- fix input value focus issue when value is loosely equal to old value [#1593](https://github.com/MithrilJS/mithril.js/issues/1593) --- @@ -37,12 +94,12 @@ #### News -- performance improvements in IE [#1598](https://github.com/lhorie/mithril.js/pull/1598) +- performance improvements in IE [#1598](https://github.com/MithrilJS/mithril.js/pull/1598) #### Bug fixes -- prevent infinite loop in non-existent default route - [#1579](https://github.com/lhorie/mithril.js/issues/1579) -- call correct lifecycle methods on children of recycled keyed vnodes - [#1609](https://github.com/lhorie/mithril.js/issues/1609) +- prevent infinite loop in non-existent default route - [#1579](https://github.com/MithrilJS/mithril.js/issues/1579) +- call correct lifecycle methods on children of recycled keyed vnodes - [#1609](https://github.com/MithrilJS/mithril.js/issues/1609) --- @@ -155,7 +212,7 @@ m("div", { // Called after the node is updated onupdate : function(vnode) { /*...*/ }, // Called before the node is removed, return a Promise that resolves when - // ready for the node to be removed from the DOM + // ready for the node to be removed from the DOM onbeforeremove : function(vnode) { /*...*/ }, // Called before the node is removed, but after onbeforeremove calls done() onremove : function(vnode) { /*...*/ } @@ -472,9 +529,9 @@ In `v0.2.x` reading route params was entirely handled through `m.route.param()`. ```javascript m.route(document.body, "/booga", { "/:attr" : { - controller : function() { - m.route.param("attr") // "booga" - }, + controller : function() { + m.route.param("attr") // "booga" + }, view : function() { m.route.param("attr") // "booga" } @@ -489,11 +546,11 @@ m.route(document.body, "/booga", { "/:attr" : { oninit : function(vnode) { vnode.attrs.attr // "booga" - m.route.param("attr") // "booga" + m.route.param("attr") // "booga" }, view : function(vnode) { vnode.attrs.attr // "booga" - m.route.param("attr") // "booga" + m.route.param("attr") // "booga" } } }) @@ -531,14 +588,14 @@ It is no longer possible to prevent unmounting via `onunload`'s `e.preventDefaul ```javascript var Component = { - controller: function() { - this.onunload = function(e) { - if (condition) e.preventDefault() - } - }, - view: function() { - return m("a[href=/]", {config: m.route}) - } + controller: function() { + this.onunload = function(e) { + if (condition) e.preventDefault() + } + }, + view: function() { + return m("a[href=/]", {config: m.route}) + } } ``` @@ -546,9 +603,9 @@ var Component = { ```javascript var Component = { - view: function() { - return m("a", {onclick: function() {if (!condition) m.route.set("/")}}) - } + view: function() { + return m("a", {onclick: function() {if (!condition) m.route.set("/")}}) + } } ``` @@ -562,14 +619,14 @@ Components no longer call `this.onunload` when they are being removed. They now ```javascript var Component = { - controller: function() { - this.onunload = function(e) { - // ... - } - }, - view: function() { - // ... - } + controller: function() { + this.onunload = function(e) { + // ... + } + }, + view: function() { + // ... + } } ``` @@ -577,12 +634,12 @@ var Component = { ```javascript var Component = { - onremove : function() { - // ... - } - view: function() { - // ... - } + onremove : function() { + // ... + } + view: function() { + // ... + } } ``` @@ -598,13 +655,13 @@ In addition, requests no longer have `m.startComputation`/`m.endComputation` sem ```javascript var data = m.request({ - method: "GET", - url: "https://api.github.com/", - initialValue: [], + method: "GET", + url: "https://api.github.com/", + initialValue: [], }) setTimeout(function() { - console.log(data()) + console.log(data()) }, 1000) ``` @@ -613,15 +670,15 @@ setTimeout(function() { ```javascript var data = [] m.request({ - method: "GET", - url: "https://api.github.com/", + method: "GET", + url: "https://api.github.com/", }) .then(function (responseBody) { - data = responseBody + data = responseBody }) setTimeout(function() { - console.log(data) // note: not a getter-setter + console.log(data) // note: not a getter-setter }, 1000) ``` @@ -653,11 +710,11 @@ greetAsync() ```javascript var greetAsync = function() { - return new Promise(function(resolve){ - setTimeout(function() { - resolve("hello") - }, 1000) - }) + return new Promise(function(resolve){ + setTimeout(function() { + resolve("hello") + }, 1000) + }) } greetAsync() @@ -679,7 +736,7 @@ m.sync([ m.request({ method: 'GET', url: 'https://api.github.com/users/isiahmeadows' }), ]) .then(function (users) { - console.log("Contributors:", users[0].name, "and", users[1].name) + console.log("Contributors:", users[0].name, "and", users[1].name) }) ``` @@ -691,7 +748,7 @@ Promise.all([ m.request({ method: 'GET', url: 'https://api.github.com/users/isiahmeadows' }), ]) .then(function (users) { - console.log("Contributors:", users[0].name, "and", users[1].name) + console.log("Contributors:", users[0].name, "and", users[1].name) }) ``` @@ -706,7 +763,7 @@ In `v0.2.x`, the `xlink` namespace was the only supported attribute namespace, a ```javascript m("svg", // the `href` attribute is namespaced automatically - m("image[href='image.gif']") + m("image[href='image.gif']") ) ``` @@ -715,7 +772,7 @@ m("svg", ```javascript m("svg", // User-specified namespace on the `href` attribute - m("image[xlink:href='image.gif']") + m("image[xlink:href='image.gif']") ) ``` diff --git a/docs/code-of-conduct.md b/docs/code-of-conduct.md new file mode 100644 index 00000000..50ebe61d --- /dev/null +++ b/docs/code-of-conduct.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +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 +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. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/docs/components.md b/docs/components.md index 229f1293..4df9dfe4 100644 --- a/docs/components.md +++ b/docs/components.md @@ -316,7 +316,7 @@ var Login = { Normally, in the context of a larger application, a login component like the one above exists alongside components for user registration and password recovery. Imagine that we want to be able to prepopulate the email field when navigating from the login screen to the registration or password recovery screens (or vice versa), so that the user doesn't need to re-type their email if they happened to fill the wrong page (or maybe you want to bump the user to the registration form if a username is not found). -Right away, we see that sharing the `username` and `password` fields from this component to another is difficult. This is because the fat component encapsulates its our state, which by definition makes this state difficult to access from outside. +Right away, we see that sharing the `username` and `password` fields from this component to another is difficult. This is because the fat component encapsulates its state, which by definition makes this state difficult to access from outside. It makes more sense to refactor this component and pull the state code out of the component and into the application's data layer. This can be as simple as creating a new module: diff --git a/docs/contributing.md b/docs/contributing.md index 5b4770d3..189d0b99 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -4,7 +4,7 @@ ## How do I go about contributing ideas or new features? -Create an [issue thread on Github](https://github.com/lhorie/mithril.js/issues/new) to suggest your idea so the community can discuss it. +Create an [issue thread on GitHub](https://github.com/MithrilJS/mithril.js/issues/new) to suggest your idea so the community can discuss it. If the consensus is that it's a good idea, the fastest way to get it into a release is to send a pull request. Without a PR, the time to implement the feature will depend on the bandwidth of the development team and its list of priorities. @@ -20,12 +20,12 @@ Ideally, the best way to report bugs is to provide a small snippet of code where To send a pull request: -- fork the repo (button at the top right in Github) -- clone the forked repo to your computer (green button in Github) +- fork the repo (button at the top right in GitHub) +- clone the forked repo to your computer (green button in GitHub) - create a feature branch (run `git checkout -b the-feature-branch-name`) - make your changes - run the tests (run `npm t`) -- submit a pull request (go to the pull requests tab in Github, click the green button and select your feature branch) +- submit a pull request (go to the pull requests tab in GitHub, click the green button and select your feature branch) diff --git a/docs/es6.md b/docs/es6.md index 3c19613f..0192a6ca 100644 --- a/docs/es6.md +++ b/docs/es6.md @@ -5,7 +5,7 @@ --- -Mithril is written in ES5, and is fully compatible with ES6 as well. ES6 is a recent update to Javascript that introduces new syntax sugar for various common cases. It's not yet fully supported by all major browsers and it's not a requirement for writing application, but it may be pleasing to use depending on your team's preferences. +Mithril is written in ES5, and is fully compatible with ES6 as well. ES6 is a recent update to Javascript that introduces new syntax sugar for various common cases. It's not yet fully supported by all major browsers and it's not a requirement for writing an application, but it may be pleasing to use depending on your team's preferences. In some limited environments, it's possible to use a significant subset of ES6 directly without extra tooling (for example, in internal applications that do not support IE). However, for the vast majority of use cases, a compiler toolchain like [Babel](https://babeljs.io) is required to compile ES6 features down to ES5. @@ -70,10 +70,12 @@ Create a `.babelrc` file: Next, create a file called `webpack.config.js` ```javascript +const path = require('path') + module.exports = { entry: './src/index.js', output: { - path: './bin', + path: path.resolve(__dirname, './bin'), filename: 'app.js', }, module: { diff --git a/docs/examples.md b/docs/examples.md index 140ad3f6..749900cc 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -2,10 +2,10 @@ Here are some examples of Mithril in action -- [Animation](http://cdn.rawgit.com/lhorie/mithril.js/rewrite/examples/animation/mosaic.html) -- [DBMonster](http://cdn.rawgit.com/lhorie/mithril.js/rewrite/examples/dbmonster/mithril/index.html) -- [Markdown Editor](http://cdn.rawgit.com/lhorie/mithril.js/rewrite/examples/editor/index.html) -- SVG: [Clock](http://cdn.rawgit.com/lhorie/mithril.js/rewrite/examples/svg/clock.html), [Ring](http://cdn.rawgit.com/lhorie/mithril.js/rewrite/examples/svg/ring.html), [Tiger](http://cdn.rawgit.com/lhorie/mithril.js/rewrite/examples/svg/tiger.html) -- [ThreadItJS](http://cdn.rawgit.com/lhorie/mithril.js/rewrite/examples/threaditjs/index.html) -- [TodoMVC](http://cdn.rawgit.com/lhorie/mithril.js/rewrite/examples/todomvc/index.html) +- [Animation](http://cdn.rawgit.com/MithrilJS/mithril.js/master/examples/animation/mosaic.html) +- [DBMonster](http://cdn.rawgit.com/MithrilJS/mithril.js/master/examples/dbmonster/mithril/index.html) +- [Markdown Editor](http://cdn.rawgit.com/MithrilJS/mithril.js/master/examples/editor/index.html) +- SVG: [Clock](http://cdn.rawgit.com/MithrilJS/mithril.js/master/examples/svg/clock.html), [Ring](http://cdn.rawgit.com/MithrilJS/mithril.js/master/examples/svg/ring.html), [Tiger](http://cdn.rawgit.com/MithrilJS/mithril.js/master/examples/svg/tiger.html) +- [ThreadItJS](http://cdn.rawgit.com/MithrilJS/mithril.js/master/examples/threaditjs/index.html) +- [TodoMVC](http://cdn.rawgit.com/MithrilJS/mithril.js/master/examples/todomvc/index.html) diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 00000000..61807fae Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/favicon.png b/docs/favicon.png new file mode 100644 index 00000000..50712630 Binary files /dev/null and b/docs/favicon.png differ diff --git a/docs/framework-comparison.md b/docs/framework-comparison.md index cf8a3483..0969b2c9 100644 --- a/docs/framework-comparison.md +++ b/docs/framework-comparison.md @@ -74,7 +74,7 @@ What these numbers show is that not only does Mithril initializes significantly Update performance can be even more important than first-render performance, since updates can happen many times while a Single Page Application is running. -A useful tool to benchmark update performance is a tool developed by the Ember team called DbMonster. It updates a table as fast as it can and measures frames per second (FPS) and Javascript times (min, max and mean). The FPS count can be difficult to evaluate since it also includes browser repaint times and `setTimeout` clamping delay, so the most meaningful number to look at is the mean render time. You can compare a [React implementation](http://cdn.rawgit.com/lhorie/mithril.js/rewrite/examples/dbmonster/react/index.html) and a [Mithril implementation](http://cdn.rawgit.com/lhorie/mithril.js/rewrite/examples/dbmonster/mithril/index.html). Sample results are shown below: +A useful tool to benchmark update performance is a tool developed by the Ember team called DbMonster. It updates a table as fast as it can and measures frames per second (FPS) and Javascript times (min, max and mean). The FPS count can be difficult to evaluate since it also includes browser repaint times and `setTimeout` clamping delay, so the most meaningful number to look at is the mean render time. You can compare a [React implementation](http://cdn.rawgit.com/MithrilJS/mithril.js/master/examples/dbmonster/react/index.html) and a [Mithril implementation](http://cdn.rawgit.com/MithrilJS/mithril.js/master/examples/dbmonster/mithril/index.html). Sample results are shown below: React | Mithril ------- | ------- @@ -139,7 +139,7 @@ Also, remember that frameworks like Angular and Mithril are designed for non-tri ##### Update performance -A useful tool to benchmark update performance is a tool developed by the Ember team called DbMonster. It updates a table as fast as it can and measures frames per second (FPS) and Javascript times (min, max and mean). The FPS count can be difficult to evaluate since it also includes browser repaint times and `setTimeout` clamping delay, so the most meaningful number to look at is the mean render time. You can compare an [Angular implementation](http://cdn.rawgit.com/lhorie/mithril.js/rewrite/examples/dbmonster/angular/index.html) and a [Mithril implementation](http://cdn.rawgit.com/lhorie/mithril.js/rewrite/examples/dbmonster/mithril/index.html). Both implementations are naive (i.e. no optimizations). Sample results are shown below: +A useful tool to benchmark update performance is a tool developed by the Ember team called DbMonster. It updates a table as fast as it can and measures frames per second (FPS) and Javascript times (min, max and mean). The FPS count can be difficult to evaluate since it also includes browser repaint times and `setTimeout` clamping delay, so the most meaningful number to look at is the mean render time. You can compare an [Angular implementation](http://cdn.rawgit.com/MithrilJS/mithril.js/master/examples/dbmonster/angular/index.html) and a [Mithril implementation](http://cdn.rawgit.com/MithrilJS/mithril.js/master/examples/dbmonster/mithril/index.html). Both implementations are naive (i.e. no optimizations). Sample results are shown below: Angular | Mithril ------- | ------- @@ -193,7 +193,7 @@ Library load times matter in applications that don't stay open for long periods ##### Update performance -A useful tool to benchmark update performance is a tool developed by the Ember team called DbMonster. It updates a table as fast as it can and measures frames per second (FPS) and Javascript times (min, max and mean). The FPS count can be difficult to evaluate since it also includes browser repaint times and `setTimeout` clamping delay, so the most meaningful number to look at is the mean render time. You can compare a [Vue implementation](http://cdn.rawgit.com/lhorie/mithril.js/rewrite/examples/dbmonster/vue/index.html) and a [Mithril implementation](http://cdn.rawgit.com/lhorie/mithril.js/rewrite/examples/dbmonster/mithril/index.html). Both implementations are naive (i.e. no optimizations). Sample results are shown below: +A useful tool to benchmark update performance is a tool developed by the Ember team called DbMonster. It updates a table as fast as it can and measures frames per second (FPS) and Javascript times (min, max and mean). The FPS count can be difficult to evaluate since it also includes browser repaint times and `setTimeout` clamping delay, so the most meaningful number to look at is the mean render time. You can compare a [Vue implementation](http://cdn.rawgit.com/MithrilJS/mithril.js/master/examples/dbmonster/vue/index.html) and a [Mithril implementation](http://cdn.rawgit.com/MithrilJS/mithril.js/master/examples/dbmonster/mithril/index.html). Both implementations are naive (i.e. no optimizations). Sample results are shown below: Vue | Mithril ------ | ------- diff --git a/docs/generate.js b/docs/generate.js index 52bc3e70..5650ae9d 100644 --- a/docs/generate.js +++ b/docs/generate.js @@ -12,9 +12,6 @@ try {fs.mkdirSync("./dist/archive/v" + version)} catch (e) {/* ignore */} var guides = fs.readFileSync("docs/nav-guides.md", "utf-8") var methods = fs.readFileSync("docs/nav-methods.md", "utf-8") -var index = fs.readFileSync("docs/index.md", "utf-8") -fs.writeFileSync("README.md", index.replace(/(\]\()(.+?)\.md(\))/g, "$1http://mithril.js.org/$2.html$3"), "utf-8") - generate("docs") function generate(pathname) { @@ -27,6 +24,7 @@ function generate(pathname) { if (pathname.match(/\.md$/)) { var outputFilename = pathname.replace(/\.md$/, ".html") var markdown = fs.readFileSync(pathname, "utf-8") + var anchors = {} var fixed = markdown .replace(/`((?:\S| -> |, )+)(\|)(\S+)`/gim, function(match, a, b, c) { // fix pipes in code tags return "" + (a + b + c).replace(/\|/g, "|") + "" @@ -48,19 +46,26 @@ function generate(pathname) { var title = fixed.match(/^#([^\n\r]+)/i) || [] var html = layout .replace(/Mithril\.js<\/title>/, "<title>" + title[1] + " - Mithril.js") - .replace(/\[version\]/, version) // update version + .replace(/\[version\]/g, version) // update version .replace(/\[body\]/, markedHtml) .replace(/(.+?)<\/h.>/gim, function(match, n, id, text) { // fix anchors var anchor = text.toLowerCase().replace(/<(\/?)code>/g, "").replace(/.+?<\/a>/g, "").replace(/\.|\[|\]|"|\/|\(|\)/g, "").replace(/\s/g, "-"); + if(anchor in anchors) { + anchor += ++anchors[anchor] + } else { + anchors[anchor] = 0; + } + return `${text}`; }) fs.writeFileSync("./dist/archive/v" + version + "/" + outputFilename.replace(/^docs\//, ""), html, "utf-8") fs.writeFileSync("./dist/" + outputFilename.replace(/^docs\//, ""), html, "utf-8") } else if (!pathname.match(/lint|generate/)) { - fs.writeFileSync("./dist/archive/v" + version + "/" + pathname.replace(/^docs\//, ""), fs.readFileSync(pathname, "utf-8"), "utf-8") - fs.writeFileSync("./dist/" + pathname.replace(/^docs\//, ""), fs.readFileSync(pathname, "utf-8"), "utf-8") + var encoding = (/\.(ico|png)$/i).test(path.extname(pathname)) ? "binary" : "utf-8"; + fs.writeFileSync("./dist/archive/v" + version + "/" + pathname.replace(/^docs\//, ""), fs.readFileSync(pathname, encoding), encoding) + fs.writeFileSync("./dist/" + pathname.replace(/^docs\//, ""), fs.readFileSync(pathname, encoding), encoding) } } } diff --git a/docs/index.md b/docs/index.md index 86aca2cc..22bb9021 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,7 +50,7 @@ Mithril supports browsers all the way back to IE9, no polyfills required. ### Getting started -The easiest way to try out Mithril is to include it from a CDN, and follow this tutorial. It'll cover the majority of the API surface (including routing and XHR) but it'll only take 10 minutes. +An easy way to try out Mithril is to include it from a CDN and follow this tutorial. It'll cover the majority of the API surface (including routing and XHR) but it'll only take 10 minutes. Let's create an HTML file to follow along: @@ -65,6 +65,13 @@ Let's create an HTML file to follow along: ``` +To make things simpler you can fork this pen which already has the latest version of mithril loaded. + +

See the Pen Mithril Scaffold by Pat Cavit (@tivac) on CodePen.

+ + +Mithril is also loaded onto this page already, so you can start poking at the `m` object in the developer console right away if you'd like! + --- ### Hello world @@ -85,6 +92,11 @@ m.render(root, "My first app") As you can see, you use the same code to both create and update HTML. Mithril automatically figures out the most efficient way of updating the text, rather than blindly recreating it from scratch. +#### Live Example + +

See the Pen Mithril Hello World by Pat Cavit (@tivac) on CodePen.

+ + --- ### DOM elements @@ -119,6 +131,11 @@ m("main", [ ]) ``` +#### Live Example + +

See the Pen Simple Mithril Example by Pat Cavit (@tivac) on CodePen.

+ + Note: If you prefer `` syntax, [it's possible to use it via a Babel plugin](jsx.md). ```jsx @@ -185,6 +202,11 @@ You can now update the label of the button by clicking the button. Since we used If you're wondering about performance, it turns out Mithril is very fast at rendering updates, because it only touches the parts of the DOM it absolutely needs to. So in our example above, when you click the button, the text in it is the only part of the DOM Mithril actually updates. +#### Live Example + +

See the Pen Mithril Component Example by Pat Cavit (@tivac) on CodePen.

+ + --- ### Routing @@ -218,6 +240,11 @@ The `"/splash"` right after `root` means that's the default route, i.e. if the h Also, as you would expect, clicking on the link on the splash page takes you to the click counter screen we created earlier. Notice that now your URL will point to `http://localhost/#!/hello`. You can navigate back and forth to the splash page using the browser's back and next button. +#### Live Example + +

See the Pen Mithril Routing Example by Pat Cavit (@tivac) on CodePen.

+ + --- ### XHR @@ -260,6 +287,11 @@ var Hello = { Clicking the button should now update the count. +#### Live Example + +

See the Pen Mithril XHR Example by Pat Cavit (@tivac) on CodePen.

+ + --- We covered how to create and update HTML, how to create components, routes for a Single Page Application, and interacted with a server via XHR. diff --git a/docs/installation.md b/docs/installation.md index f0c527bc..c4604ad5 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -219,7 +219,7 @@ If you don't have the ability to run a bundler script due to company security po Hello world - + diff --git a/docs/jsx.md b/docs/jsx.md index 9ce6163c..1c1b92ef 100644 --- a/docs/jsx.md +++ b/docs/jsx.md @@ -39,7 +39,7 @@ When using JSX, it's possible to interpolate Javascript expressions within JSX t var greeting = "Hello" var url = "http://google.com" var link = {greeting + "!"} -// yields Hello +// yields Hello! ``` Components can be used by using a convention of uppercasing the first letter of the component name: @@ -98,7 +98,7 @@ npm install babel-core babel-loader babel-preset-es2015 babel-plugin-transform-r Create a `.babelrc` file: -``` +```json { "presets": ["es2015"], "plugins": [ @@ -112,10 +112,12 @@ Create a `.babelrc` file: Next, create a file called `webpack.config.js` ```javascript +const path = require('path') + module.exports = { entry: './src/index.js', output: { - path: './bin', + path: path.resolve(__dirname, './bin'), filename: 'app.js', }, module: { @@ -128,6 +130,8 @@ module.exports = { } ``` +For those familiar with Webpack already, please note that adding the Babel options to the `babel-loader` section of your `webpack.config.js` will throw an error, so you need to include them in the separate `.babelrc` file. + This configuration assumes the source code file for the application entry point is in `src/index.js`, and this will output the bundle to `bin/app.js`. To run the bundler, setup an npm script. Open `package.json` and add this entry under `"scripts"`: diff --git a/docs/layout.html b/docs/layout.html index f5ce0231..110a890b 100644 --- a/docs/layout.html +++ b/docs/layout.html @@ -4,6 +4,7 @@ Mithril.js + @@ -14,8 +15,8 @@ @@ -26,8 +27,9 @@ License: MIT. © Leo Horie. - - + + + + + + + + + + + + + + + + + + + diff --git a/performance/test-perf.js b/performance/test-perf.js new file mode 100644 index 00000000..2ba9b622 --- /dev/null +++ b/performance/test-perf.js @@ -0,0 +1,336 @@ +/* global Benchmark */ +"use strict" + +/* Based off of preact's perf tests, so including their MIT license */ +/* +The MIT License (MIT) + +Copyright (c) 2017 Jason Miller + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +var browserMock = require("../test-utils/browserMock") + +// Do this silly dance so browser testing works +var B = typeof Benchmark === "undefined" ? require("benchmark") : Benchmark + +var m, scratch; + +// set up browser env on before running tests +var doc = typeof document !== "undefined" ? document : null + +if(!doc) { + var mock = browserMock() + if (typeof global !== "undefined") { global.window = mock } + + doc = mock.document +} + +// Have to include mithril AFTER browser polyfill is set up +m = require("../mithril") // eslint-disable-line global-require + +scratch = doc.createElement("div"); + +(doc.body || doc.documentElement).appendChild(scratch) + +// Initialize benchmark suite +var suite = new B.Suite("mithril perf") + +suite.on("start", function() { + this.start = Date.now(); +}) + +suite.on("cycle", function(e) { + console.log(e.target.toString()) + + scratch.innerHTML = "" +}) + +suite.on("complete", function() { + console.log("Completed perf tests in " + (Date.now() - this.start) + "ms") +}) + +suite.on("error", console.error.bind(console)) + +suite.add({ + name : "rerender without changes", + onStart : function() { + this.vdom = m("div", {class: "foo bar", "data-foo": "bar", p: 2}, + m("header", + m("h1", {class: "asdf"}, "a ", "b", " c ", 0, " d"), + m("nav", + m("a", {href: "/foo"}, "Foo"), + m("a", {href: "/bar"}, "Bar") + ) + ), + m("main", + m("form", {onSubmit: function onSubmit() {}}, + m("input", {type: "checkbox", checked: true}), + m("input", {type: "checkbox", checked: false}), + m("fieldset", + m("label", + m("input", {type: "radio", checked: true}) + ), + m("label", + m("input", {type: "radio"}) + ) + ), + m("button-bar", + m("button", + {style: "width:10px; height:10px; border:1px solid #FFF;"}, + "Normal CSS" + ), + m("button", + {style: "top:0 ; right: 20"}, + "Poor CSS" + ), + m("button", + {style: "invalid-prop:1;padding:1px;font:12px/1.1 arial,sans-serif;", icon: true}, + "Poorer CSS" + ), + m("button", + {style: {margin: 0, padding: "10px", overflow: "visible"}}, + "Object CSS" + ) + ) + ) + ) + ) + }, + fn : function() { + m.render(scratch, this.vdom) + } +}) + +suite.add({ + name : "construct large VDOM tree", + + onStart : function() { + var fields = [] + + for(var i=100; i--;) { + fields.push((i * 999).toString(36)) + } + + this.fields = fields; + }, + + fn : function () { + m("div", {class: "foo bar", "data-foo": "bar", p: 2}, + m("header", + m("h1", {class: "asdf"}, "a ", "b", " c ", 0, " d"), + m("nav", + m("a", {href: "/foo"}, "Foo"), + m("a", {href: "/bar"}, "Bar") + ) + ), + m("main", + m("form", + {onSubmit: function onSubmit() {}}, + m("input", {type: "checkbox", checked: true}), + m("input", {type: "checkbox"}), + m("fieldset", + this.fields.map(function (field) { + return m("label", + field, + ":", + m("input", {placeholder: field}) + ) + }) + ), + m("button-bar", + m("button", + {style: "width:10px; height:10px; border:1px solid #FFF;"}, + "Normal CSS" + ), + m("button", + {style: "top:0 ; right: 20"}, + "Poor CSS" + ), + m("button", + {style: "invalid-prop:1;padding:1px;font:12px/1.1 arial,sans-serif;", icon: true}, + "Poorer CSS" + ), + m("button", + {style: {margin: 0, padding: "10px", overflow: "visible"}}, + "Object CSS" + ) + ) + ) + ) + ) + } +}) + +suite.add({ + name : "mutate styles/properties", + + onStart : function () { + var counter = 0 + var keyLooper = function (n) { return function (c) { return c % n ? (c + "px") : c } } + var get = function (obj, i) { return obj[i%obj.length] } + var classes = ["foo", "foo bar", "", "baz-bat", null, "fooga"] + var styles = [] + var multivalue = ["0 1px", "0 0 1px 0", "0", "1px", "20px 10px", "7em 5px", "1px 0 5em 2px"] + var stylekeys = [ + ["left", keyLooper(3)], + ["top", keyLooper(2)], + ["margin", function (c) { return get(multivalue, c).replace("1px", c+"px") }], + ["padding", function (c) { return get(multivalue, c) }], + ["position", function (c) { return c%5 ? c%2 ? "absolute" : "relative" : null }], + ["display", function (c) { return c%10 ? c%2 ? "block" : "inline" : "none" }], + ["color", function (c) { return ("rgba(" + (c%255) + ", " + (255 - c%255) + ", " + (50+c%150) + ", " + (c%50/50) + ")") }], + ["border", function (c) { return c%5 ? ((c%10) + "px " + (c%2?"solid":"dotted") + " " + (stylekeys[6][1](c))) : "" }] + ] + var i, j, style, conf + + for (i=0; i<1000; i++) { + style = {} + for (j=0; j 0) attrs.className = classes.join(" ") @@ -34,8 +34,8 @@ function execSelector(state, attrs, children) { } } - if (className != null) { - if (attrs.class != null) { + if (className !== undefined) { + if (attrs.class !== undefined) { attrs.class = undefined attrs.className = className } diff --git a/render/render.js b/render/render.js index df26a1df..e24a76eb 100644 --- a/render/render.js +++ b/render/render.js @@ -6,9 +6,18 @@ module.exports = function($window) { var $doc = $window.document var $emptyFragment = $doc.createDocumentFragment() + var nameSpace = { + svg: "http://www.w3.org/2000/svg", + math: "http://www.w3.org/1998/Math/MathML" + } + var onevent function setEventCallback(callback) {return onevent = callback} + function getNameSpace(vnode) { + return vnode.attrs && vnode.attrs.xmlns || nameSpace[vnode.tag] + } + //create function createNodes(parent, vnodes, start, end, hooks, nextSibling, ns) { for (var i = start; i < end; i++) { @@ -66,14 +75,11 @@ module.exports = function($window) { } function createElement(parent, vnode, hooks, ns, nextSibling) { var tag = vnode.tag - switch (vnode.tag) { - case "svg": ns = "http://www.w3.org/2000/svg"; break - case "math": ns = "http://www.w3.org/1998/Math/MathML"; break - } - var attrs = vnode.attrs var is = attrs && attrs.is + ns = getNameSpace(vnode) || ns + var element = ns ? is ? $doc.createElementNS(ns, tag, {is: is}) : $doc.createElementNS(ns, tag) : is ? $doc.createElement(tag, {is: is}) : $doc.createElement(tag) @@ -140,7 +146,7 @@ module.exports = function($window) { //update function updateNodes(parent, old, vnodes, recycling, hooks, nextSibling, ns) { if (old === vnodes || old == null && vnodes == null) return - else if (old == null) createNodes(parent, vnodes, 0, vnodes.length, hooks, nextSibling, undefined) + 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 (old.length === vnodes.length) { @@ -218,7 +224,7 @@ module.exports = function($window) { if (movable.dom != null) nextSibling = movable.dom } else { - var dom = createNode(parent, v, hooks, undefined, nextSibling) + var dom = createNode(parent, v, hooks, ns, nextSibling) nextSibling = dom } } @@ -289,10 +295,8 @@ module.exports = function($window) { } function updateElement(old, vnode, recycling, hooks, ns) { var element = vnode.dom = old.dom - switch (vnode.tag) { - case "svg": ns = "http://www.w3.org/2000/svg"; break - case "math": ns = "http://www.w3.org/1998/Math/MathML"; break - } + ns = getNameSpace(vnode) || ns + if (vnode.tag === "textarea") { if (vnode.attrs == null) vnode.attrs = {} if (vnode.text != null) { @@ -476,12 +480,21 @@ module.exports = function($window) { else if (key[0] === "o" && key[1] === "n" && typeof value === "function") updateEvent(vnode, key, value) else if (key === "style") updateStyle(element, old, value) else if (key in element && !isAttribute(key) && ns === undefined && !isCustomElement(vnode)) { - //setting input[value] to same value by typing on focused element moves cursor to end in Chrome - if (vnode.tag === "input" && key === "value" && vnode.dom.value == value && vnode.dom === $doc.activeElement) return - //setting select[value] to same value while having select open blinks select dropdown in Chrome - if (vnode.tag === "select" && key === "value" && vnode.dom.value == value && vnode.dom === $doc.activeElement) return - //setting option[value] to same value while having select open blinks select dropdown in Chrome - if (vnode.tag === "option" && key === "value" && vnode.dom.value == value) return + if (key === "value") { + var normalized = "" + value // eslint-disable-line no-implicit-coercion + //setting input[value] to same value by typing on focused element moves cursor to end in Chrome + if ((vnode.tag === "input" || vnode.tag === "textarea") && vnode.dom.value === normalized && vnode.dom === $doc.activeElement) return + //setting select[value] to same value while having select open blinks select dropdown in Chrome + if (vnode.tag === "select") { + if (value === null) { + if (vnode.dom.selectedIndex === -1 && vnode.dom === $doc.activeElement) return + } else { + if (old !== null && vnode.dom.value === normalized && vnode.dom === $doc.activeElement) return + } + } + //setting option[value] to same value while having select open blinks select dropdown in Chrome + if (vnode.tag === "option" && old != null && vnode.dom.value === normalized) return + } // If you assign an input type that is not supported by IE 11 with an assignment expression, an error will occur. if (vnode.tag === "input" && key === "type") { element.setAttribute(key, value) @@ -600,12 +613,13 @@ module.exports = function($window) { if (!dom) throw new Error("Ensure the DOM element being passed to m.route/m.mount/m.render is not undefined.") var hooks = [] var active = $doc.activeElement + var namespace = dom.namespaceURI // First time rendering into a node clears it out if (dom.vnodes == null) dom.textContent = "" if (!Array.isArray(vnodes)) vnodes = [vnodes] - updateNodes(dom, dom.vnodes, Vnode.normalizeChildren(vnodes), false, hooks, null, undefined) + 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]() if ($doc.activeElement !== active) active.focus() diff --git a/render/tests/index.html b/render/tests/index.html index b978ae6f..eda51921 100644 --- a/render/tests/index.html +++ b/render/tests/index.html @@ -8,7 +8,7 @@ - + diff --git a/render/tests/test-attributes.js b/render/tests/test-attributes.js index c6a3df31..fcc7f150 100644 --- a/render/tests/test-attributes.js +++ b/render/tests/test-attributes.js @@ -11,6 +11,46 @@ o.spec("attributes", function() { root = $window.document.body render = vdom($window).render }) + o.spec("basics", function() { + o("works (create/update/remove)", function() { + + var a = {tag: "div", attrs: {}} + var b = {tag: "div", attrs: {id: "test"}} + var c = {tag: "div", attrs: {}} + + render(root, [a]); + + o(a.dom.hasAttribute("id")).equals(false) + + render(root, [b]); + + o(b.dom.getAttribute("id")).equals("test") + + render(root, [c]); + + o(c.dom.hasAttribute("id")).equals(false) + }) + o("undefined attr is equivalent to a lack of attr", function() { + var a = {tag: "div", attrs: {id: undefined}} + var b = {tag: "div", attrs: {id: "test"}} + var c = {tag: "div", attrs: {id: undefined}} + + render(root, [a]); + + o(a.dom.hasAttribute("id")).equals(false) + + render(root, [b]); + + o(b.dom.hasAttribute("id")).equals(true) + o(b.dom.getAttribute("id")).equals("test") + + render(root, [c]); + + // #1804 + // TODO: uncomment + // o(c.dom.hasAttribute("id")).equals(false) + }) + }) o.spec("customElements", function(){ o("when vnode is customElement, custom setAttribute called", function(){ @@ -54,7 +94,7 @@ o.spec("attributes", function() { render(root, [a]) - o(a.dom.attributes["readonly"].nodeValue).equals("") + o(a.dom.attributes["readonly"].value).equals("") }) o("when input readonly is false, attribute is not present", function() { var a = {tag: "input", attrs: {readonly: false}} @@ -96,6 +136,196 @@ o.spec("attributes", function() { o(a.dom.attributes["checked"]).equals(undefined) }) }) + o.spec("input.value", function() { + o("can be set as text", function() { + var a = {tag: "input", attrs: {value: "test"}} + + render(root, [a]); + + o(a.dom.value).equals("test") + }) + o("a lack of attribute removes `value`", function() { + var a = {tag: "input", attrs: {}} + var b = {tag: "input", attrs: {value: "test"}} + // var c = {tag: "input", attrs: {}} + + render(root, [a]) + + o(a.dom.value).equals("") + + render(root, [b]) + + o(a.dom.value).equals("test") + + // https://github.com/MithrilJS/mithril.js/issues/1804#issuecomment-304521235 + // TODO: Uncomment + // render(root, [c]) + + // o(a.dom.value).equals("") + }) + o("can be set as number", function() { + var a = {tag: "input", attrs: {value: 1}} + + render(root, [a]); + + o(a.dom.value).equals("1") + }) + o("null becomes the empty string", function() { + var a = {tag: "input", attrs: {value: null}} + var b = {tag: "input", attrs: {value: "test"}} + var c = {tag: "input", attrs: {value: null}} + + render(root, [a]); + + o(a.dom.value).equals("") + o(a.dom.getAttribute("value")).equals(null) + + render(root, [b]); + + o(b.dom.value).equals("test") + o(b.dom.getAttribute("value")).equals(null) + + render(root, [c]); + + o(c.dom.value).equals("") + o(c.dom.getAttribute("value")).equals(null) + }) + o("'' and 0 are different values", function() { + var a = {tag: "input", attrs: {value: 0}, children:[{tag:"#", children:""}]} + var b = {tag: "input", attrs: {value: ""}, children:[{tag:"#", children:""}]} + var c = {tag: "input", attrs: {value: 0}, children:[{tag:"#", children:""}]} + + render(root, [a]); + + o(a.dom.value).equals("0") + + render(root, [b]); + + o(b.dom.value).equals("") + + // #1595 redux + render(root, [c]); + + o(c.dom.value).equals("0") + }) + o("isn't set when equivalent to the previous value and focused", function() { + var $window = domMock({spy: o.spy}) + var root = $window.document.body + var render = vdom($window).render + + var a = {tag: "input"} + var b = {tag: "input", attrs: {value: "1"}} + var c = {tag: "input", attrs: {value: "1"}} + var d = {tag: "input", attrs: {value: 1}} + var e = {tag: "input", attrs: {value: 2}} + + render(root, [a]) + var spies = $window.__getSpies(a.dom) + a.dom.focus() + + o(spies.valueSetter.callCount).equals(0) + + render(root, [b]) + + o(b.dom.value).equals("1") + o(spies.valueSetter.callCount).equals(1) + + render(root, [c]) + + o(c.dom.value).equals("1") + o(spies.valueSetter.callCount).equals(1) + + render(root, [d]) + + o(d.dom.value).equals("1") + o(spies.valueSetter.callCount).equals(1) + + render(root, [e]) + + o(d.dom.value).equals("2") + o(spies.valueSetter.callCount).equals(2) + }) + }) + o.spec("input.type", function() { + o("the input.type setter is never used", function() { + var $window = domMock({spy: o.spy}) + var root = $window.document.body + var render = vdom($window).render + + var a = {tag: "input", attrs: {type: "radio"}} + var b = {tag: "input", attrs: {type: "text"}} + var c = {tag: "input", attrs: {}} + + render(root, [a]) + var spies = $window.__getSpies(a.dom) + + o(spies.typeSetter.callCount).equals(0) + o(a.dom.getAttribute("type")).equals("radio") + + render(root, [b]) + + o(spies.typeSetter.callCount).equals(0) + o(b.dom.getAttribute("type")).equals("text") + + render(root, [c]) + + o(spies.typeSetter.callCount).equals(0) + o(c.dom.hasAttribute("type")).equals(false) + }) + }) + o.spec("textarea.value", function() { + o("can be removed by not passing a value", function() { + var a = {tag: "textarea", attrs: {value:"x"}} + // var b = {tag: "textarea", attrs: {}} + + render(root, [a]) + + o(a.dom.value).equals("x") + + // https://github.com/MithrilJS/mithril.js/issues/1804#issuecomment-304521235 + // TODO: Uncomment + // render(root, [b]) + + // o(b.dom.value).equals("") + }) + o("isn't set when equivalent to the previous value and focused", function() { + var $window = domMock({spy: o.spy}) + var root = $window.document.body + var render = vdom($window).render + + var a = {tag: "textarea"} + var b = {tag: "textarea", attrs: {value: "1"}} + var c = {tag: "textarea", attrs: {value: "1"}} + var d = {tag: "textarea", attrs: {value: 1}} + var e = {tag: "textarea", attrs: {value: 2}} + + render(root, [a]) + var spies = $window.__getSpies(a.dom) + a.dom.focus() + + o(spies.valueSetter.callCount).equals(0) + + render(root, [b]) + + o(b.dom.value).equals("1") + o(spies.valueSetter.callCount).equals(1) + + render(root, [c]) + + o(c.dom.value).equals("1") + o(spies.valueSetter.callCount).equals(1) + + render(root, [d]) + + o(d.dom.value).equals("1") + o(spies.valueSetter.callCount).equals(1) + + render(root, [e]) + + o(d.dom.value).equals("2") + o(spies.valueSetter.callCount).equals(2) + }) + }) o.spec("link href", function() { o("when link href is true, attribute is present", function() { var a = {tag: "a", attrs: {href: true}} @@ -118,7 +348,7 @@ o.spec("attributes", function() { render(root, canvas) - o(canvas.dom.attributes["width"].nodeValue).equals("100%") + o(canvas.dom.attributes["width"].value).equals("100%") o(canvas.dom.width).equals(100) }) }) @@ -128,7 +358,218 @@ o.spec("attributes", function() { render(root, [a]); - o(a.dom.attributes["class"].nodeValue).equals("test") + o(a.dom.attributes["class"].value).equals("test") + }) + }) + o.spec("option.value", function() { + o("can be set as text", function() { + var a = {tag: "option", attrs: {value: "test"}} + + render(root, [a]); + + o(a.dom.value).equals("test") + }) + o("can be set as number", function() { + var a = {tag: "option", attrs: {value: 1}} + + render(root, [a]); + + o(a.dom.value).equals("1") + }) + o("null becomes the empty string", function() { + var a = {tag: "option", attrs: {value: null}} + var b = {tag: "option", attrs: {value: "test"}} + var c = {tag: "option", attrs: {value: null}} + + render(root, [a]); + + o(a.dom.value).equals("") + o(a.dom.getAttribute("value")).equals("") + + render(root, [b]); + + o(b.dom.value).equals("test") + o(b.dom.getAttribute("value")).equals("test") + + render(root, [c]); + + o(c.dom.value).equals("") + o(c.dom.getAttribute("value")).equals("") + }) + o("'' and 0 are different values", function() { + var a = {tag: "option", attrs: {value: 0}, children:[{tag:"#", children:""}]} + var b = {tag: "option", attrs: {value: ""}, children:[{tag:"#", children:""}]} + var c = {tag: "option", attrs: {value: 0}, children:[{tag:"#", children:""}]} + + render(root, [a]); + + o(a.dom.value).equals("0") + + render(root, [b]); + + o(a.dom.value).equals("") + + // #1595 redux + render(root, [c]); + + o(c.dom.value).equals("0") + }) + o("isn't set when equivalent to the previous value", function() { + var $window = domMock({spy: o.spy}) + var root = $window.document.body + var render = vdom($window).render + + var a = {tag: "option"} + var b = {tag: "option", attrs: {value: "1"}} + var c = {tag: "option", attrs: {value: "1"}} + var d = {tag: "option", attrs: {value: 1}} + var e = {tag: "option", attrs: {value: 2}} + + render(root, [a]) + var spies = $window.__getSpies(a.dom) + + o(spies.valueSetter.callCount).equals(0) + + render(root, [b]) + + o(b.dom.value).equals("1") + o(spies.valueSetter.callCount).equals(1) + + render(root, [c]) + + o(c.dom.value).equals("1") + o(spies.valueSetter.callCount).equals(1) + + render(root, [d]) + + o(d.dom.value).equals("1") + o(spies.valueSetter.callCount).equals(1) + + render(root, [e]) + + o(d.dom.value).equals("2") + o(spies.valueSetter.callCount).equals(2) + }) + }) + o.spec("select.value", function() { + function makeSelect(value) { + var attrs = (arguments.length === 0) ? {} : {value: value} + return {tag: "select", attrs: attrs, children: [ + {tag:"option", attrs: {value: "1"}}, + {tag:"option", attrs: {value: "2"}}, + {tag:"option", attrs: {value: "a"}}, + {tag:"option", attrs: {value: "0"}}, + {tag:"option", attrs: {value: ""}} + ]} + } + o("can be set as text", function() { + var a = makeSelect() + var b = makeSelect("2") + var c = makeSelect("a") + + render(root, [a]) + + o(a.dom.value).equals("1") + o(a.dom.selectedIndex).equals(0) + + render(root, [b]) + + o(b.dom.value).equals("2") + o(b.dom.selectedIndex).equals(1) + + render(root, [c]) + + o(c.dom.value).equals("a") + o(c.dom.selectedIndex).equals(2) + }) + o("setting null unsets the value", function() { + var a = makeSelect(null) + + render(root, [a]) + + o(a.dom.value).equals("") + o(a.dom.selectedIndex).equals(-1) + }) + o("values are type converted", function() { + var a = makeSelect(1) + var b = makeSelect(2) + + render(root, [a]) + + o(a.dom.value).equals("1") + o(a.dom.selectedIndex).equals(0) + + render(root, [b]) + + o(b.dom.value).equals("2") + o(b.dom.selectedIndex).equals(1) + }) + o("'' and 0 are different values when focused", function() { + var a = makeSelect("") + var b = makeSelect(0) + + render(root, [a]) + a.dom.focus() + + o(a.dom.value).equals("") + + // #1595 redux + render(root, [b]) + + o(b.dom.value).equals("0") + }) + o("'' and null are different values when focused", function() { + var a = makeSelect("") + var b = makeSelect(null) + var c = makeSelect("") + + render(root, [a]) + a.dom.focus() + + o(a.dom.value).equals("") + o(a.dom.selectedIndex).equals(4) + + render(root, [b]) + + o(b.dom.value).equals("") + o(b.dom.selectedIndex).equals(-1) + + render(root, [c]) + + o(c.dom.value).equals("") + o(c.dom.selectedIndex).equals(4) + }) + o("updates with the same value do not re-set the attribute if the select has focus", function() { + var $window = domMock({spy: o.spy}) + var root = $window.document.body + var render = vdom($window).render + + var a = makeSelect() + var b = makeSelect("1") + var c = makeSelect(1) + var d = makeSelect("2") + + render(root, [a]) + var spies = $window.__getSpies(a.dom) + a.dom.focus() + + o(spies.valueSetter.callCount).equals(0) + o(a.dom.value).equals("1") + + render(root, [b]) + + o(spies.valueSetter.callCount).equals(0) + o(b.dom.value).equals("1") + + render(root, [c]) + + o(spies.valueSetter.callCount).equals(0) + o(c.dom.value).equals("1") + + render(root, [d]) + + o(spies.valueSetter.callCount).equals(1) + o(d.dom.value).equals("2") }) }) o.spec("contenteditable throws on untrusted children", function() { diff --git a/render/tests/test-component.js b/render/tests/test-component.js index 94863236..c391ddd2 100644 --- a/render/tests/test-component.js +++ b/render/tests/test-component.js @@ -30,7 +30,7 @@ o.spec("component", function() { render(root, [node]) o(root.firstChild.nodeName).equals("DIV") - o(root.firstChild.attributes["id"].nodeValue).equals("a") + o(root.firstChild.attributes["id"].value).equals("a") o(root.firstChild.firstChild.nodeValue).equals("b") }) o("receives arguments", function() { @@ -44,7 +44,7 @@ o.spec("component", function() { render(root, [node]) o(root.firstChild.nodeName).equals("DIV") - o(root.firstChild.attributes["id"].nodeValue).equals("a") + o(root.firstChild.attributes["id"].value).equals("a") o(root.firstChild.firstChild.nodeValue).equals("b") }) o("updates", function() { @@ -57,7 +57,7 @@ o.spec("component", function() { render(root, [{tag: component, attrs: {id: "c"}, text: "d"}]) o(root.firstChild.nodeName).equals("DIV") - o(root.firstChild.attributes["id"].nodeValue).equals("c") + o(root.firstChild.attributes["id"].value).equals("c") o(root.firstChild.firstChild.nodeValue).equals("d") }) o("updates root from null", function() { @@ -400,7 +400,7 @@ o.spec("component", function() { o(called).equals(1) o(root.firstChild.nodeName).equals("DIV") - o(root.firstChild.attributes["id"].nodeValue).equals("a") + o(root.firstChild.attributes["id"].value).equals("a") o(root.firstChild.firstChild.nodeValue).equals("b") }) o("calls oninit when returning fragment", function() { @@ -423,7 +423,7 @@ o.spec("component", function() { o(called).equals(1) o(root.firstChild.nodeName).equals("DIV") - o(root.firstChild.attributes["id"].nodeValue).equals("a") + o(root.firstChild.attributes["id"].value).equals("a") o(root.firstChild.firstChild.nodeValue).equals("b") }) o("calls oninit before view", function() { @@ -479,7 +479,7 @@ o.spec("component", function() { o(called).equals(1) o(root.firstChild.nodeName).equals("DIV") - o(root.firstChild.attributes["id"].nodeValue).equals("a") + o(root.firstChild.attributes["id"].value).equals("a") o(root.firstChild.firstChild.nodeValue).equals("b") }) o("does not calls oncreate on redraw", function() { @@ -520,7 +520,7 @@ o.spec("component", function() { o(called).equals(1) o(root.firstChild.nodeName).equals("DIV") - o(root.firstChild.attributes["id"].nodeValue).equals("a") + o(root.firstChild.attributes["id"].value).equals("a") o(root.firstChild.firstChild.nodeValue).equals("b") }) o("calls onupdate", function() { @@ -546,7 +546,7 @@ o.spec("component", function() { o(called).equals(1) o(root.firstChild.nodeName).equals("DIV") - o(root.firstChild.attributes["id"].nodeValue).equals("a") + o(root.firstChild.attributes["id"].value).equals("a") o(root.firstChild.firstChild.nodeValue).equals("b") }) o("calls onupdate when returning fragment", function() { @@ -572,7 +572,7 @@ o.spec("component", function() { o(called).equals(1) o(root.firstChild.nodeName).equals("DIV") - o(root.firstChild.attributes["id"].nodeValue).equals("a") + o(root.firstChild.attributes["id"].value).equals("a") o(root.firstChild.firstChild.nodeValue).equals("b") }) o("calls onremove", function() { diff --git a/render/tests/test-createElement.js b/render/tests/test-createElement.js index 9e2827cf..6e3dcdd7 100644 --- a/render/tests/test-createElement.js +++ b/render/tests/test-createElement.js @@ -24,8 +24,8 @@ o.spec("createElement", function() { render(root, [vnode]) o(vnode.dom.nodeName).equals("DIV") - o(vnode.dom.attributes["id"].nodeValue).equals("a") - o(vnode.dom.attributes["title"].nodeValue).equals("b") + o(vnode.dom.attributes["id"].value).equals("a") + o(vnode.dom.attributes["title"].value).equals("b") }) o("creates style", function() { var vnode = {tag: "div", attrs: {style: {backgroundColor: "red"}}} @@ -48,28 +48,34 @@ o.spec("createElement", function() { render(root, [vnode]) o(vnode.dom.nodeName).equals("DIV") - o(vnode.dom.attributes["id"].nodeValue).equals("a") - o(vnode.dom.attributes["title"].nodeValue).equals("b") + o(vnode.dom.attributes["id"].value).equals("a") + o(vnode.dom.attributes["title"].value).equals("b") o(vnode.dom.childNodes.length).equals(2) o(vnode.dom.childNodes[0].nodeName).equals("A") o(vnode.dom.childNodes[1].nodeName).equals("B") }) o("creates svg", function() { - var vnode = {tag: "svg", ns: "http://www.w3.org/2000/svg", children: [{tag: "a", ns: "http://www.w3.org/2000/svg", attrs: {"xlink:href": "javascript:;"}}]} + var vnode = {tag: "svg", ns: "http://www.w3.org/2000/svg", children: [ + {tag: "a", ns: "http://www.w3.org/2000/svg", attrs: {"xlink:href": "javascript:;"}}, + {tag: "foreignObject", children: [{tag: "body", attrs: {xmlns: "http://www.w3.org/1999/xhtml"}}]} + ]} render(root, [vnode]) o(vnode.dom.nodeName).equals("svg") o(vnode.dom.namespaceURI).equals("http://www.w3.org/2000/svg") o(vnode.dom.firstChild.nodeName).equals("a") o(vnode.dom.firstChild.namespaceURI).equals("http://www.w3.org/2000/svg") - o(vnode.dom.firstChild.attributes["href"].nodeValue).equals("javascript:;") + o(vnode.dom.firstChild.attributes["href"].value).equals("javascript:;") o(vnode.dom.firstChild.attributes["href"].namespaceURI).equals("http://www.w3.org/1999/xlink") + o(vnode.dom.childNodes[1].nodeName).equals("foreignObject") + o(vnode.dom.childNodes[1].firstChild.nodeName).equals("body") + o(vnode.dom.childNodes[1].firstChild.namespaceURI).equals("http://www.w3.org/1999/xhtml") }) o("sets attributes correctly for svg", function() { var vnode = {tag: "svg", ns: "http://www.w3.org/2000/svg", attrs: {viewBox: "0 0 100 100"}} render(root, [vnode]) - o(vnode.dom.attributes["viewBox"].nodeValue).equals("0 0 100 100") + o(vnode.dom.attributes["viewBox"].value).equals("0 0 100 100") }) o("creates mathml", function() { var vnode = {tag: "math", ns: "http://www.w3.org/1998/Math/MathML", children: [{tag: "mrow", ns: "http://www.w3.org/1998/Math/MathML"}]} diff --git a/render/tests/test-event.js b/render/tests/test-event.js index 31d2d012..1f7eec2a 100644 --- a/render/tests/test-event.js +++ b/render/tests/test-event.js @@ -69,7 +69,7 @@ o.spec("event", function() { o(onevent.args[0].type).equals("click") o(onevent.args[0].target).equals(div.dom) o(div.dom).equals(updated.dom) - o(div.dom.attributes["id"].nodeValue).equals("b") + o(div.dom.attributes["id"].value).equals("b") }) o("handles ontransitionend", function() { diff --git a/render/tests/test-hyperscript.js b/render/tests/test-hyperscript.js index cf0b3155..498a9dbf 100644 --- a/render/tests/test-hyperscript.js +++ b/render/tests/test-hyperscript.js @@ -16,6 +16,95 @@ o.spec("hyperscript", function() { o(vnode.tag).equals("a") }) + o("v1.0.1 bug-for-bug regression suite", function(){ + o(m("a", { + class: null + }).attrs).deepEquals({ + class: undefined, + className: null + }) + o(m("a", { + class: undefined + }).attrs).deepEquals({ + class: undefined, + }) + o(m("a", { + class: false + }).attrs).deepEquals({ + class: undefined, + className: false + }) + o(m("a", { + class: true + }).attrs).deepEquals({ + class: undefined, + className: true + }) + o(m("a.x", { + class: null + }).attrs).deepEquals({ + class: undefined, + className: "x null" + }) + o(m("a.x", { + class: undefined + }).attrs).deepEquals({ + class: undefined, + className: "x" + }) + o(m("a.x", { + class: false + }).attrs).deepEquals({ + class: undefined, + className: "x false" + }) + o(m("a.x", { + class: true + }).attrs).deepEquals({ + class: undefined, + className: "x true" + }) + o(m("a", { + className: null + }).attrs).deepEquals({ + className: null + }) + o(m("a", { + className: undefined + }).attrs).deepEquals({ + className: undefined + }) + o(m("a", { + className: false + }).attrs).deepEquals({ + className: false + }) + o(m("a", { + className: true + }).attrs).deepEquals({ + className: true + }) + o(m("a.x", { + className: null + }).attrs).deepEquals({ + className: "x" + }) + o(m("a.x", { + className: undefined + }).attrs).deepEquals({ + className: "x" + }) + o(m("a.x", { + className: false + }).attrs).deepEquals({ + className: "x" + }) + o(m("a.x", { + className: true + }).attrs).deepEquals({ + className: "x true" + }) + }) o("handles class in selector", function() { var vnode = m(".a") @@ -129,6 +218,18 @@ o.spec("hyperscript", function() { o(vnode.tag).equals("div") o(vnode.attrs.a).equals(true) }) + o("handles explicit empty string value for input", function() { + var vnode = m('input[value=""]') + + o(vnode.tag).equals("input") + o(vnode.attrs.value).equals("") + }) + o("handles explicit empty string value for option", function() { + var vnode = m('option[value=""]') + + o(vnode.tag).equals("option") + o(vnode.attrs.value).equals("") + }) }) o.spec("attrs", function() { o("handles string attr", function() { diff --git a/render/tests/test-input.js b/render/tests/test-input.js index 73eecf1e..d61bad54 100644 --- a/render/tests/test-input.js +++ b/render/tests/test-input.js @@ -80,6 +80,57 @@ o.spec("form inputs", function() { o(select.dom.selectedIndex).equals(0) }) + o("select option can have empty string value", function() { + var select = {tag: "select", children :[ + {tag: "option", attrs: {value: ""}, text: "aaa"} + ]} + + render(root, [select]) + + o(select.dom.firstChild.value).equals("") + }) + + o("option value defaults to textContent unless explicitly set", function() { + var select = {tag: "select", children :[ + {tag: "option", text: "aaa"} + ]} + + render(root, [select]) + + o(select.dom.firstChild.value).equals("aaa") + o(select.dom.value).equals("aaa") + + //test that value changes when content changes + select = {tag: "select", children :[ + {tag: "option", text: "bbb"} + ]} + + render(root, [select]) + + o(select.dom.firstChild.value).equals("bbb") + o(select.dom.value).equals("bbb") + + //test that value can be set to "" in subsequent render + select = {tag: "select", children :[ + {tag: "option", attrs: {value: ""}, text: "aaa"} + ]} + + render(root, [select]) + + o(select.dom.firstChild.value).equals("") + o(select.dom.value).equals("") + + //test that value reverts to textContent when value omitted + select = {tag: "select", children :[ + {tag: "option", text: "aaa"} + ]} + + render(root, [select]) + + o(select.dom.firstChild.value).equals("aaa") + o(select.dom.value).equals("aaa") + }) + o("select yields invalid value without children", function() { var select = {tag: "select", attrs: {value: "a"}} diff --git a/render/tests/test-onbeforeremove.js b/render/tests/test-onbeforeremove.js index 2e23215b..0e83d4a0 100644 --- a/render/tests/test-onbeforeremove.js +++ b/render/tests/test-onbeforeremove.js @@ -199,9 +199,8 @@ o.spec("onbeforeremove", function() { render(root, [{tag: component}]) render(root, []) + o(onremove.callCount).equals(0) callAsync(function(){ - o(onremove.callCount).equals(0) - callAsync(function() { o(onremove.callCount).equals(1) done() diff --git a/render/tests/test-onbeforeupdate.js b/render/tests/test-onbeforeupdate.js index 9814122b..d8b2f2d6 100644 --- a/render/tests/test-onbeforeupdate.js +++ b/render/tests/test-onbeforeupdate.js @@ -21,7 +21,7 @@ o.spec("onbeforeupdate", function() { render(root, [vnode]) render(root, [updated]) - o(root.firstChild.attributes["id"].nodeValue).equals("a") + o(root.firstChild.attributes["id"].value).equals("a") }) o("prevents update in text", function() { @@ -65,7 +65,7 @@ o.spec("onbeforeupdate", function() { render(root, [vnode]) render(root, [updated]) - o(root.firstChild.attributes["id"].nodeValue).equals("b") + o(root.firstChild.attributes["id"].value).equals("b") }) o("accepts arguments for comparison", function() { @@ -86,7 +86,7 @@ o.spec("onbeforeupdate", function() { } o(count).equals(1) - o(root.firstChild.attributes["id"].nodeValue).equals("b") + o(root.firstChild.attributes["id"].value).equals("b") }) o("is not called on creation", function() { @@ -167,7 +167,7 @@ o.spec("onbeforeupdate", function() { render(root, [vnode]) render(root, [updated]) - o(root.firstChild.attributes["id"].nodeValue).equals("a") + o(root.firstChild.attributes["id"].value).equals("a") }) o("does not prevent update if returning true in component and true in vnode", function() { @@ -183,7 +183,7 @@ o.spec("onbeforeupdate", function() { render(root, [vnode]) render(root, [updated]) - o(root.firstChild.attributes["id"].nodeValue).equals("b") + o(root.firstChild.attributes["id"].value).equals("b") }) o("does not prevent update if returning false in component but true in vnode", function() { @@ -199,7 +199,7 @@ o.spec("onbeforeupdate", function() { render(root, [vnode]) render(root, [updated]) - o(root.firstChild.attributes["id"].nodeValue).equals("b") + o(root.firstChild.attributes["id"].value).equals("b") }) o("does not prevent update if returning true in component but false in vnode", function() { @@ -215,7 +215,7 @@ o.spec("onbeforeupdate", function() { render(root, [vnode]) render(root, [updated]) - o(root.firstChild.attributes["id"].nodeValue).equals("b") + o(root.firstChild.attributes["id"].value).equals("b") }) o("does not prevent update if returning true from component", function() { @@ -231,7 +231,7 @@ o.spec("onbeforeupdate", function() { render(root, [vnode]) render(root, [updated]) - o(root.firstChild.attributes["id"].nodeValue).equals("b") + o(root.firstChild.attributes["id"].value).equals("b") }) o("accepts arguments for comparison in component", function() { @@ -258,7 +258,7 @@ o.spec("onbeforeupdate", function() { } o(count).equals(1) - o(root.firstChild.attributes["id"].nodeValue).equals("b") + o(root.firstChild.attributes["id"].value).equals("b") }) o("is not called on component creation", function() { diff --git a/render/tests/test-onupdate.js b/render/tests/test-onupdate.js index 13f62c46..4b74d288 100644 --- a/render/tests/test-onupdate.js +++ b/render/tests/test-onupdate.js @@ -170,9 +170,9 @@ o.spec("onupdate", function() { function update(vnode) { called = true - o(vnode.dom.parentNode.attributes["id"].nodeValue).equals("11") - o(vnode.dom.attributes["id"].nodeValue).equals("22") - o(vnode.dom.childNodes[0].attributes["id"].nodeValue).equals("33") + o(vnode.dom.parentNode.attributes["id"].value).equals("11") + o(vnode.dom.attributes["id"].value).equals("22") + o(vnode.dom.childNodes[0].attributes["id"].value).equals("33") } o(called).equals(true) }) diff --git a/render/tests/test-render.js b/render/tests/test-render.js index 92bcd93c..292433a0 100644 --- a/render/tests/test-render.js +++ b/render/tests/test-render.js @@ -271,4 +271,32 @@ o.spec("render", function() { o(updateA.callCount).equals(2) o(removeA.callCount).equals(1) }) + o("svg namespace is preserved in keyed diff (#1820)", function(){ + // note that this only exerciese one branch of the keyed diff algo + var svg = {tag:"svg", children: [ + {tag:"g", key: 0}, + {tag:"g", key: 1} + ]} + render(root, [svg]) + + o(svg.dom.namespaceURI).equals("http://www.w3.org/2000/svg") + o(svg.dom.childNodes[0].namespaceURI).equals("http://www.w3.org/2000/svg") + o(svg.dom.childNodes[1].namespaceURI).equals("http://www.w3.org/2000/svg") + + svg = {tag:"svg", children: [ + {tag:"g", key: 1, attrs: {x: 1}}, + {tag:"g", key: 2, attrs: {x: 2}} + ]} + render(root, [svg]) + + o(svg.dom.namespaceURI).equals("http://www.w3.org/2000/svg") + o(svg.dom.childNodes[0].namespaceURI).equals("http://www.w3.org/2000/svg") + o(svg.dom.childNodes[1].namespaceURI).equals("http://www.w3.org/2000/svg") + }) + o("the namespace of the root is passed to children", function() { + render(root, [{tag: "svg"}]) + o(root.childNodes[0].namespaceURI).equals("http://www.w3.org/2000/svg") + render(root.childNodes[0], [{tag: "g"}]) + o(root.childNodes[0].childNodes[0].namespaceURI).equals("http://www.w3.org/2000/svg") + }) }) diff --git a/render/tests/test-updateElement.js b/render/tests/test-updateElement.js index 1774e61f..313fe1a9 100644 --- a/render/tests/test-updateElement.js +++ b/render/tests/test-updateElement.js @@ -21,7 +21,7 @@ o.spec("updateElement", function() { o(updated.dom).equals(vnode.dom) o(updated.dom).equals(root.firstChild) - o(updated.dom.attributes["id"].nodeValue).equals("c") + o(updated.dom.attributes["id"].value).equals("c") }) o("adds attr", function() { var vnode = {tag: "a", attrs: {id: "b"}} @@ -32,7 +32,7 @@ o.spec("updateElement", function() { o(updated.dom).equals(vnode.dom) o(updated.dom).equals(root.firstChild) - o(updated.dom.attributes["title"].nodeValue).equals("d") + o(updated.dom.attributes["title"].value).equals("d") }) o("adds attr from empty attrs", function() { var vnode = {tag: "a"} @@ -43,7 +43,7 @@ o.spec("updateElement", function() { o(updated.dom).equals(vnode.dom) o(updated.dom).equals(root.firstChild) - o(updated.dom.attributes["title"].nodeValue).equals("d") + o(updated.dom.attributes["title"].value).equals("d") }) o("removes attr", function() { var vnode = {tag: "a", attrs: {id: "b", title: "d"}} @@ -209,7 +209,7 @@ o.spec("updateElement", function() { render(root, [vnode]) render(root, [updated]) - o(updated.dom.attributes["class"].nodeValue).equals("b") + o(updated.dom.attributes["class"].value).equals("b") }) o("updates svg child", function() { var vnode = {tag: "svg", children: [{ diff --git a/request/request.js b/request/request.js index 76e218c9..ac5f3391 100644 --- a/request/request.js +++ b/request/request.js @@ -93,7 +93,8 @@ module.exports = function($window, Promise) { } else { var error = new Error(xhr.responseText) - for (var key in response) error[key] = response[key] + error.code = xhr.status + error.response = response reject(error) } } diff --git a/request/tests/test-request.js b/request/tests/test-request.js index 7f965498..94e7e172 100644 --- a/request/tests/test-request.js +++ b/request/tests/test-request.js @@ -458,9 +458,10 @@ o.spec("xhr", function() { xhr({method: "GET", url: "/item"}).catch(function(e) { o(e instanceof Error).equals(true) o(e.message).equals(JSON.stringify({error: "error"})) + o(e.code).equals(500) }).then(done) }) - o("extends Error with JSON response", function(done) { + o("adds response to Error", function(done) { mock.$defineRoutes({ "GET /item": function() { return {status: 500, responseText: JSON.stringify({message: "error", stack: "error on line 1"})} @@ -468,8 +469,8 @@ o.spec("xhr", function() { }) xhr({method: "GET", url: "/item"}).catch(function(e) { o(e instanceof Error).equals(true) - o(e.message).equals("error") - o(e.stack).equals("error on line 1") + o(e.response.message).equals("error") + o(e.response.stack).equals("error on line 1") }).then(done) }) o("rejects on non-JSON server error", function(done) { diff --git a/stream/LICENSE b/stream/LICENSE new file mode 100644 index 00000000..2aae0f1e --- /dev/null +++ b/stream/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Leo Horie + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/stream/README.md b/stream/README.md new file mode 100644 index 00000000..d4cb92c1 --- /dev/null +++ b/stream/README.md @@ -0,0 +1,6 @@ +mithril-stream [![NPM Version](https://img.shields.io/npm/v/mithril-stream.svg)](https://www.npmjs.com/package/mithril-stream) [![NPM License](https://img.shields.io/npm/l/mithril-stream.svg)](https://www.npmjs.com/package/mithril-stream) +============== + +Mithril's `m.stream` as a standalone module. + +See [mithril.js.org/stream.html](https://mithril.js.org/stream.html) for docs/usage. diff --git a/stream/package.json b/stream/package.json index f48972d5..f5f4f86c 100644 --- a/stream/package.json +++ b/stream/package.json @@ -1,6 +1,6 @@ { "name": "mithril-stream", - "version": "1.0.0", + "version": "1.1.0", "description": "Streaming data, mithril-style", "main": "stream.js", "directories": { @@ -9,5 +9,5 @@ "keywords": [ "stream", "reactive", "data" ], "author": "Leo Horie ", "license": "MIT", - "repository": "lhorie/mithril.js" + "repository": "MithrilJS/mithril.js" } diff --git a/stream/stream.js b/stream/stream.js index 18c7e608..fd2dcffb 100644 --- a/stream/stream.js +++ b/stream/stream.js @@ -1,6 +1,7 @@ -"use strict" - +/* eslint-disable */ ;(function() { +"use strict" +/* eslint-enable */ var guid = 0, HALT = {} function createStream() { diff --git a/test-utils/domMock.js b/test-utils/domMock.js index a827070a..060ba4f7 100644 --- a/test-utils/domMock.js +++ b/test-utils/domMock.js @@ -1,6 +1,39 @@ "use strict" -module.exports = function() { +/* +Known limitations: + +- `option.selected` can't be set/read when the option doesn't have a `select` parent +- `element.attributes` is just a map of attribute names => Attr objects stubs +- ... + +*/ + +/* +options: +- spy:(f: Function) => Function +*/ + +module.exports = function(options) { + options = options || {} + var spy = options.spy || function(f){return f} + var spymap = [] + function registerSpies(element, spies) { + if(options.spy) { + var i = spymap.indexOf(element) + if (i === -1) { + spymap.push(element, spies) + } else { + var existing = spymap[i + 1] + for (var k in spies) existing[k] = spies[k] + } + } + } + function getSpies(element) { + if (element == null || typeof element !== "object") throw new Error("Element expected") + if(options.spy) return spymap[spymap.indexOf(element) + 1] + } + function isModernEvent(type) { return type === "transitionstart" || type === "transitionend" || type === "animationstart" || type === "animationend" } @@ -62,14 +95,26 @@ module.exports = function() { } function getAttribute(name) { if (this.attributes[name] == null) return null - return this.attributes[name].nodeValue + return this.attributes[name].value } function setAttribute(name, value) { - var nodeValue = String(value) + /*eslint-disable no-implicit-coercion*/ + // this is the correct kind of conversion, passing a Symbol throws in browsers too. + var nodeValue = "" + value + /*eslint-enable no-implicit-coercion*/ + this.attributes[name] = { namespaceURI: null, + get value() {return nodeValue}, + set value(value) { + /*eslint-disable no-implicit-coercion*/ + nodeValue = "" + value + /*eslint-enable no-implicit-coercion*/ + }, get nodeValue() {return nodeValue}, - set nodeValue(value) {nodeValue = String(value)}, + set nodeValue(value) { + this.value = value + } } } function setAttributeNS(ns, name, value) { @@ -79,6 +124,9 @@ module.exports = function() { function removeAttribute(name) { delete this.attributes[name] } + function hasAttribute(name) { + return name in this.attributes + } var declListTokenizer = /;|"(?:\\.|[^"\n])*"|'(?:\\.|[^'\n])*'/g /** * This will split a semicolon-separated CSS declaration list into an array of @@ -150,6 +198,7 @@ module.exports = function() { appendChild: appendChild, removeChild: removeChild, insertBefore: insertBefore, + hasAttribute: hasAttribute, getAttribute: getAttribute, setAttribute: setAttribute, setAttributeNS: setAttributeNS, @@ -204,7 +253,7 @@ module.exports = function() { throw new Error("setting element.style is not portable") }, get className() { - return this.attributes["class"] ? this.attributes["class"].nodeValue : "" + return this.attributes["class"] ? this.attributes["class"].value : "" }, set className(value) { if (this.namespaceURI === "http://www.w3.org/2000/svg") throw new Error("Cannot set property className of SVGElement") @@ -222,7 +271,7 @@ module.exports = function() { } }, dispatchEvent: function(e) { - if (this.nodeName === "INPUT" && this.attributes["type"] != null && this.attributes["type"].nodeValue === "checkbox" && e.type === "click") { + if (this.nodeName === "INPUT" && this.attributes["type"] != null && this.attributes["type"].value === "checkbox" && e.type === "click") { this.checked = !this.checked } @@ -256,30 +305,73 @@ module.exports = function() { enumerable: true, }) - element.value = "" - } - - if (element.nodeName === "TEXTAREA") { - var value + var value = "" + var valueSetter = spy(function(v) { + /*eslint-disable no-implicit-coercion*/ + value = v === null ? "" : "" + v + /*eslint-enable no-implicit-coercion*/ + }) Object.defineProperty(element, "value", { get: function() { - return value != null ? value : - this.firstChild ? this.firstChild.nodeValue : "" + return value }, - set: function(v) {value = v}, + set: valueSetter, enumerable: true, }) + + // we currently emulate the non-ie behavior, but emulating ie may be more useful (throw when an invalid type is set) + var typeSetter = spy(function(v) { + this.setAttribute("type", v) + }) + Object.defineProperty(element, "type", { + get: function() { + if (!this.hasAttribute("type")) return "text" + var type = this.getAttribute("type") + return (/^(?:radio|button|checkbox|color|date|datetime|datetime-local|email|file|hidden|month|number|password|range|research|search|submit|tel|text|url|week|image)$/) + .test(type) + ? type + : "text" + }, + set: typeSetter, + enumerable: true, + }) + registerSpies(element, { + valueSetter: valueSetter, + typeSetter: typeSetter + }) + } + + + if (element.nodeName === "TEXTAREA") { + var wasNeverSet = true + var value = "" + var valueSetter = spy(function(v) { + wasNeverSet = false + /*eslint-disable no-implicit-coercion*/ + value = v === null ? "" : "" + v + /*eslint-enable no-implicit-coercion*/ + }) + Object.defineProperty(element, "value", { + get: function() { + return wasNeverSet && this.firstChild ? this.firstChild.nodeValue : value + }, + set: valueSetter, + enumerable: true, + }) + registerSpies(element, { + valueSetter: valueSetter + }) } /* eslint-disable radix */ if (element.nodeName === "CANVAS") { Object.defineProperty(element, "width", { - get: function() {return this.attributes["width"] ? Math.floor(parseInt(this.attributes["width"].nodeValue) || 0) : 300}, + get: function() {return this.attributes["width"] ? Math.floor(parseInt(this.attributes["width"].value) || 0) : 300}, set: function(value) {this.setAttribute("width", Math.floor(Number(value) || 0).toString())}, }) Object.defineProperty(element, "height", { - get: function() {return this.attributes["height"] ? Math.floor(parseInt(this.attributes["height"].nodeValue) || 0) : 300}, + get: function() {return this.attributes["height"] ? Math.floor(parseInt(this.attributes["height"].value) || 0) : 300}, set: function(value) {this.setAttribute("height", Math.floor(Number(value) || 0).toString())}, }) } @@ -296,7 +388,7 @@ module.exports = function() { } function getOptionValue(element) { return element.attributes["value"] != null ? - element.attributes["value"].nodeValue : + element.attributes["value"].value : element.firstChild != null ? element.firstChild.nodeValue : "" } if (element.nodeName === "SELECT") { @@ -317,14 +409,14 @@ module.exports = function() { }, enumerable: true, }) - Object.defineProperty(element, "value", { - get: function() { - if (this.selectedIndex > -1) return getOptionValue(getOptions(this)[this.selectedIndex]) - return "" - }, - set: function(value) { + var valueSetter = spy(function(value) { + if (value === null) { + selectedIndex = -1 + } else { var options = getOptions(this) - var stringValue = String(value) + /*eslint-disable no-implicit-coercion*/ + var stringValue = "" + value + /*eslint-enable no-implicit-coercion*/ for (var i = 0; i < options.length; i++) { if (getOptionValue(options[i]) === stringValue) { // selectedValue = stringValue @@ -334,19 +426,37 @@ module.exports = function() { } // selectedValue = stringValue selectedIndex = -1 + } + }) + Object.defineProperty(element, "value", { + get: function() { + if (this.selectedIndex > -1) return getOptionValue(getOptions(this)[this.selectedIndex]) + return "" }, + set: valueSetter, enumerable: true, }) + registerSpies(element, { + valueSetter: valueSetter + }) } if (element.nodeName === "OPTION") { + var valueSetter = spy(function(value) { + /*eslint-disable no-implicit-coercion*/ + this.setAttribute("value", value === null ? "" : "" + value) + /*eslint-enable no-implicit-coercion*/ + }) Object.defineProperty(element, "value", { get: function() {return getOptionValue(this)}, - set: function(value) { - this.setAttribute("value", value) - }, + set: valueSetter, enumerable: true, }) + registerSpies(element, { + valueSetter: valueSetter + }) + Object.defineProperty(element, "selected", { + // TODO? handle `selected` without a parent (works in browsers) get: function() { var options = getOptions(this.parentNode) var index = options.indexOf(this) @@ -372,13 +482,19 @@ module.exports = function() { return element }, createTextNode: function(text) { - var nodeValue = String(text) + /*eslint-disable no-implicit-coercion*/ + var nodeValue = "" + text + /*eslint-enable no-implicit-coercion*/ return { nodeType: 3, nodeName: "#text", parentNode: null, get nodeValue() {return nodeValue}, - set nodeValue(value) {nodeValue = String(value)}, + set nodeValue(value) { + /*eslint-disable no-implicit-coercion*/ + nodeValue = "" + value + /*eslint-enable no-implicit-coercion*/ + }, } }, createDocumentFragment: function() { @@ -409,5 +525,7 @@ module.exports = function() { $window.document.documentElement.appendChild($window.document.body) activeElement = $window.document.body + if (options.spy) $window.__getSpies = getSpies + return $window } diff --git a/test-utils/pushStateMock.js b/test-utils/pushStateMock.js index c727f393..f2273a2d 100644 --- a/test-utils/pushStateMock.js +++ b/test-utils/pushStateMock.js @@ -1,7 +1,7 @@ "use strict" var parseURL = require("../test-utils/parseURL") -var callAsync = require("../test-utils/callAsync.js") +var callAsync = require("../test-utils/callAsync") function debouncedAsync(f) { var ref diff --git a/test-utils/tests/test-domMock.js b/test-utils/tests/test-domMock.js index aee68832..a5829458 100644 --- a/test-utils/tests/test-domMock.js +++ b/test-utils/tests/test-domMock.js @@ -77,6 +77,27 @@ o.spec("domMock", function() { o(node.nodeValue).equals("true") }) + if (typeof Symbol === "function") { + o("doesn't work with symbols", function(){ + var threw = false + try { + $document.createTextNode(Symbol("nono")) + } catch(e) { + threw = true + } + o(threw).equals(true) + }) + o("symbols can't be used as nodeValue", function(){ + var threw = false + try { + var node = $document.createTextNode("a") + node.nodeValue = Symbol("nono") + } catch(e) { + threw = true + } + o(threw).equals(true) + }) + } }) o.spec("createDocumentFragment", function() { @@ -327,6 +348,7 @@ o.spec("domMock", function() { var div = $document.createElement("div") div.setAttribute("id", "aaa") + o(div.attributes["id"].value).equals("aaa") o(div.attributes["id"].nodeValue).equals("aaa") o(div.attributes["id"].namespaceURI).equals(null) }) @@ -334,32 +356,51 @@ o.spec("domMock", function() { var div = $document.createElement("div") div.setAttribute("id", 123) - o(div.attributes["id"].nodeValue).equals("123") + o(div.attributes["id"].value).equals("123") }) o("works w/ null", function() { var div = $document.createElement("div") div.setAttribute("id", null) - o(div.attributes["id"].nodeValue).equals("null") + o(div.attributes["id"].value).equals("null") }) o("works w/ undefined", function() { var div = $document.createElement("div") div.setAttribute("id", undefined) - o(div.attributes["id"].nodeValue).equals("undefined") + o(div.attributes["id"].value).equals("undefined") }) o("works w/ object", function() { var div = $document.createElement("div") div.setAttribute("id", {}) - o(div.attributes["id"].nodeValue).equals("[object Object]") + o(div.attributes["id"].value).equals("[object Object]") }) o("setting via attributes map stringifies", function() { var div = $document.createElement("div") div.setAttribute("id", "a") - div.attributes["id"].nodeValue = 123 + div.attributes["id"].value = 123 - o(div.attributes["id"].nodeValue).equals("123") + o(div.attributes["id"].value).equals("123") + + div.attributes["id"].nodeValue = 456 + + o(div.attributes["id"].value).equals("456") + }) + }) + o.spec("hasAttribute", function() { + o("works", function() { + var div = $document.createElement("div") + + o(div.hasAttribute("id")).equals(false) + + div.setAttribute("id", "aaa") + + o(div.hasAttribute("id")).equals(true) + + div.removeAttribute("id") + + o(div.hasAttribute("id")).equals(false) }) }) @@ -368,14 +409,14 @@ o.spec("domMock", function() { var div = $document.createElement("div") div.setAttributeNS("http://www.w3.org/1999/xlink", "href", "aaa") - o(div.attributes["href"].nodeValue).equals("aaa") + o(div.attributes["href"].value).equals("aaa") o(div.attributes["href"].namespaceURI).equals("http://www.w3.org/1999/xlink") }) o("works w/ number", function() { var div = $document.createElement("div") div.setAttributeNS("http://www.w3.org/1999/xlink", "href", 123) - o(div.attributes["href"].nodeValue).equals("123") + o(div.attributes["href"].value).equals("123") o(div.attributes["href"].namespaceURI).equals("http://www.w3.org/1999/xlink") }) }) @@ -416,18 +457,18 @@ o.spec("domMock", function() { o(div.childNodes[0].nodeName).equals("BR") o(div.childNodes[1].nodeType).equals(1) o(div.childNodes[1].nodeName).equals("A") - o(div.childNodes[1].attributes["class"].nodeValue).equals("aaa") - o(div.childNodes[1].attributes["id"].nodeValue).equals("xyz") + o(div.childNodes[1].attributes["class"].value).equals("aaa") + o(div.childNodes[1].attributes["id"].value).equals("xyz") o(div.childNodes[1].childNodes[0].nodeType).equals(3) o(div.childNodes[1].childNodes[0].nodeValue).equals("123") o(div.childNodes[1].childNodes[1].nodeType).equals(1) o(div.childNodes[1].childNodes[1].nodeName).equals("B") - o(div.childNodes[1].childNodes[1].attributes["class"].nodeValue).equals("bbb") + o(div.childNodes[1].childNodes[1].attributes["class"].value).equals("bbb") o(div.childNodes[1].childNodes[2].nodeType).equals(3) o(div.childNodes[1].childNodes[2].nodeValue).equals("234") o(div.childNodes[1].childNodes[3].nodeType).equals(1) o(div.childNodes[1].childNodes[3].nodeName).equals("BR") - o(div.childNodes[1].childNodes[3].attributes["class"].nodeValue).equals("ccc") + o(div.childNodes[1].childNodes[3].attributes["class"].value).equals("ccc") o(div.childNodes[1].childNodes[4].nodeType).equals(3) o(div.childNodes[1].childNodes[4].nodeValue).equals("345") }) @@ -628,14 +669,14 @@ o.spec("domMock", function() { a.setAttribute("href", "") o(a.href).notEquals("") - o(a.attributes["href"].nodeValue).equals("") + o(a.attributes["href"].value).equals("") }) o("is path if property is set", function() { var a = $document.createElement("a") a.href = "" o(a.href).notEquals("") - o(a.attributes["href"].nodeValue).equals("") + o(a.attributes["href"].value).equals("") }) }) o.spec("input[checked]", function() { @@ -656,7 +697,7 @@ o.spec("domMock", function() { input.setAttribute("checked", "") o(input.checked).equals(true) - o(input.attributes["checked"].nodeValue).equals("") + o(input.attributes["checked"].value).equals("") input.removeAttribute("checked") @@ -699,20 +740,95 @@ o.spec("domMock", function() { o("value" in input).equals(true) o("value" in a).equals(false) }) + o("converts null to ''", function() { + var input = $document.createElement("input") + input.value = "x" + + o(input.value).equals("x") + + input.value = null + + o(input.value).equals("") + }) + o("converts values to strings", function() { + var input = $document.createElement("input") + input.value = 5 + + o(input.value).equals("5") + + input.value = 0 + + o(input.value).equals("0") + + input.value = undefined + + o(input.value).equals("undefined") + }) + if (typeof Symbol === "function") o("throws when set to a symbol", function() { + var threw = false + var input = $document.createElement("input") + try { + input.value = Symbol("") + } catch (e) { + o(e instanceof TypeError).equals(true) + threw = true + } + + o(input.value).equals("") + o(threw).equals(true) + }) + }) + o.spec("input[type]", function(){ + o("only exists in input elements", function() { + var input = $document.createElement("input") + var a = $document.createElement("a") + + o("type" in input).equals(true) + o("type" in a).equals(false) + }) + o("is 'text' by default", function() { + var input = $document.createElement("input") + + o(input.type).equals("text") + }) + "radio|button|checkbox|color|date|datetime|datetime-local|email|file|hidden|month|number|password|range|research|search|submit|tel|text|url|week|image" + .split("|").forEach(function(type) { + o("can be set to " + type, function(){ + var input = $document.createElement("input") + input.type = type + + o(input.getAttribute("type")).equals(type) + o(input.type).equals(type) + }) + o("bad values set the attribute, but the getter corrects to 'text', " + type, function(){ + var input = $document.createElement("input") + input.type = "badbad" + type + + o(input.getAttribute("type")).equals("badbad" + type) + o(input.type).equals("text") + }) + }) }) o.spec("textarea[value]", function() { - o("reads from child if no value", function() { - var input = $document.createElement("textarea") - input.appendChild($document.createTextNode("aaa")) + o("reads from child if no value was ever set", function() { + var textarea = $document.createElement("textarea") + textarea.appendChild($document.createTextNode("aaa")) - o(input.value).equals("aaa") + o(textarea.value).equals("aaa") }) o("ignores child if value set", function() { - var input = $document.createElement("textarea") - input.value = "aaa" - input.setAttribute("value", "bbb") + var textarea = $document.createElement("textarea") + textarea.value = null + textarea.appendChild($document.createTextNode("aaa")) - o(input.value).equals("aaa") + o(textarea.value).equals("") + }) + o("textarea[value] doesn't reflect `attributes.value`", function() { + var textarea = $document.createElement("textarea") + textarea.value = "aaa" + textarea.setAttribute("value", "bbb") + + o(textarea.value).equals("aaa") }) }) o.spec("select[value] and select[selectedIndex]", function() { @@ -773,10 +889,76 @@ o.spec("domMock", function() { option2.setAttribute("value", "b") select.appendChild(option2) + var option3 = $document.createElement("option") + option3.setAttribute("value", "") + select.appendChild(option3) + + var option4 = $document.createElement("option") + option4.setAttribute("value", "null") + select.appendChild(option4) + select.value = "b" o(select.value).equals("b") o(select.selectedIndex).equals(1) + + select.value = "" + + o(select.value).equals("") + o(select.selectedIndex).equals(2) + + select.value = "null" + + o(select.value).equals("null") + o(select.selectedIndex).equals(3) + + select.value = null + + o(select.value).equals("") + o(select.selectedIndex).equals(-1) + }) + o("setting valid value works with type conversion", function() { + var select = $document.createElement("select") + + var option1 = $document.createElement("option") + option1.setAttribute("value", "0") + select.appendChild(option1) + + var option2 = $document.createElement("option") + option2.setAttribute("value", "undefined") + select.appendChild(option2) + + var option3 = $document.createElement("option") + option3.setAttribute("value", "") + select.appendChild(option3) + + select.value = 0 + + o(select.value).equals("0") + o(select.selectedIndex).equals(0) + + select.value = undefined + + o(select.value).equals("undefined") + o(select.selectedIndex).equals(1) + + if (typeof Symbol === "function") { + var threw = false + try { + select.value = Symbol("x") + } catch (e) { + threw = true + } + o(threw).equals(true) + o(select.value).equals("undefined") + o(select.selectedIndex).equals(1) + } + }) + o("option.value = null is converted to the empty string", function() { + var option = $document.createElement("option") + option.value = null + + o(option.value).equals("") }) o("setting valid value works with optgroup", function() { var select = $document.createElement("select") @@ -920,55 +1102,55 @@ o.spec("domMock", function() { var canvas = $document.createElement("canvas") canvas.width = 100 - o(canvas.attributes["width"].nodeValue).equals("100") + o(canvas.attributes["width"].value).equals("100") o(canvas.width).equals(100) canvas.height = 100 - o(canvas.attributes["height"].nodeValue).equals("100") + o(canvas.attributes["height"].value).equals("100") o(canvas.height).equals(100) }) o("setting string casts to number", function() { var canvas = $document.createElement("canvas") canvas.width = "100" - o(canvas.attributes["width"].nodeValue).equals("100") + o(canvas.attributes["width"].value).equals("100") o(canvas.width).equals(100) canvas.height = "100" - o(canvas.attributes["height"].nodeValue).equals("100") + o(canvas.attributes["height"].value).equals("100") o(canvas.height).equals(100) }) o("setting float casts to int", function() { var canvas = $document.createElement("canvas") canvas.width = 1.2 - o(canvas.attributes["width"].nodeValue).equals("1") + o(canvas.attributes["width"].value).equals("1") o(canvas.width).equals(1) canvas.height = 1.2 - o(canvas.attributes["height"].nodeValue).equals("1") + o(canvas.attributes["height"].value).equals("1") o(canvas.height).equals(1) }) o("setting percentage fails", function() { var canvas = $document.createElement("canvas") canvas.width = "100%" - o(canvas.attributes["width"].nodeValue).equals("0") + o(canvas.attributes["width"].value).equals("0") o(canvas.width).equals(0) canvas.height = "100%" - o(canvas.attributes["height"].nodeValue).equals("0") + o(canvas.attributes["height"].value).equals("0") o(canvas.height).equals(0) }) o("setting attribute works", function() { var canvas = $document.createElement("canvas") canvas.setAttribute("width", "100%") - o(canvas.attributes["width"].nodeValue).equals("100%") + o(canvas.attributes["width"].value).equals("100%") o(canvas.width).equals(100) canvas.setAttribute("height", "100%") - o(canvas.attributes["height"].nodeValue).equals("100%") + o(canvas.attributes["height"].value).equals("100%") o(canvas.height).equals(100) }) }) @@ -979,7 +1161,7 @@ o.spec("domMock", function() { el.className = "a" o(el.className).equals("a") - o(el.attributes["class"].nodeValue).equals("a") + o(el.attributes["class"].value).equals("a") }) o("setter throws in svg", function(done) { var el = $document.createElementNS("http://www.w3.org/2000/svg", "svg") @@ -991,4 +1173,85 @@ o.spec("domMock", function() { } }) }) + o.spec("spies", function() { + var $window + o.beforeEach(function() { + $window = domMock({spy: o.spy}) + }) + o("basics", function() { + o(typeof $window.__getSpies).equals("function") + o("__getSpies" in domMock()).equals(false) + }) + o("input elements have spies on value and type setters", function() { + var input = $window.document.createElement("input") + + var spies = $window.__getSpies(input) + + o(typeof spies).equals("object") + o(spies).notEquals(null) + o(typeof spies.valueSetter).equals("function") + o(typeof spies.typeSetter).equals("function") + o(spies.valueSetter.callCount).equals(0) + o(spies.typeSetter.callCount).equals(0) + + input.value = "aaa" + input.type = "radio" + + o(spies.valueSetter.callCount).equals(1) + o(spies.valueSetter.this).equals(input) + o(spies.valueSetter.args[0]).equals("aaa") + + o(spies.typeSetter.callCount).equals(1) + o(spies.typeSetter.this).equals(input) + o(spies.typeSetter.args[0]).equals("radio") + }) + o("select elements have spies on value setters", function() { + var select = $window.document.createElement("select") + + var spies = $window.__getSpies(select) + + o(typeof spies).equals("object") + o(spies).notEquals(null) + o(typeof spies.valueSetter).equals("function") + o(spies.valueSetter.callCount).equals(0) + + select.value = "aaa" + + o(spies.valueSetter.callCount).equals(1) + o(spies.valueSetter.this).equals(select) + o(spies.valueSetter.args[0]).equals("aaa") + }) + o("option elements have spies on value setters", function() { + var option = $window.document.createElement("option") + + var spies = $window.__getSpies(option) + + o(typeof spies).equals("object") + o(spies).notEquals(null) + o(typeof spies.valueSetter).equals("function") + o(spies.valueSetter.callCount).equals(0) + + option.value = "aaa" + + o(spies.valueSetter.callCount).equals(1) + o(spies.valueSetter.this).equals(option) + o(spies.valueSetter.args[0]).equals("aaa") + }) + o("textarea elements have spies on value setters", function() { + var textarea = $window.document.createElement("textarea") + + var spies = $window.__getSpies(textarea) + + o(typeof spies).equals("object") + o(spies).notEquals(null) + o(typeof spies.valueSetter).equals("function") + o(spies.valueSetter.callCount).equals(0) + + textarea.value = "aaa" + + o(spies.valueSetter.callCount).equals(1) + o(spies.valueSetter.this).equals(textarea) + o(spies.valueSetter.args[0]).equals("aaa") + }) + }) }) diff --git a/test-utils/tests/test-throttleMock.js b/test-utils/tests/test-throttleMock.js new file mode 100644 index 00000000..69920623 --- /dev/null +++ b/test-utils/tests/test-throttleMock.js @@ -0,0 +1,91 @@ +"use strict" + +var o = require("../../ospec/ospec") +var throttleMocker = require("../../test-utils/throttleMock") + +o.spec("throttleMock", function() { + o("works with one callback", function() { + var throttleMock = throttleMocker() + var spy = o.spy() + + o(throttleMock.queueLength()).equals(0) + + var throttled = throttleMock.throttle(spy) + + o(throttleMock.queueLength()).equals(0) + o(spy.callCount).equals(0) + + throttled() + + o(throttleMock.queueLength()).equals(1) + o(spy.callCount).equals(0) + + throttled() + + o(throttleMock.queueLength()).equals(1) + o(spy.callCount).equals(0) + + throttleMock.fire() + + o(throttleMock.queueLength()).equals(0) + o(spy.callCount).equals(1) + + throttleMock.fire() + + o(spy.callCount).equals(1) + }) + o("works with two callbacks", function() { + var throttleMock = throttleMocker() + var spy1 = o.spy() + var spy2 = o.spy() + + o(throttleMock.queueLength()).equals(0) + + var throttled1 = throttleMock.throttle(spy1) + + o(throttleMock.queueLength()).equals(0) + o(spy1.callCount).equals(0) + o(spy2.callCount).equals(0) + + throttled1() + + o(throttleMock.queueLength()).equals(1) + o(spy1.callCount).equals(0) + o(spy2.callCount).equals(0) + + throttled1() + + o(throttleMock.queueLength()).equals(1) + o(spy1.callCount).equals(0) + o(spy2.callCount).equals(0) + + var throttled2 = throttleMock.throttle(spy2) + + o(throttleMock.queueLength()).equals(1) + o(spy1.callCount).equals(0) + o(spy2.callCount).equals(0) + + throttled2() + + o(throttleMock.queueLength()).equals(2) + o(spy1.callCount).equals(0) + o(spy2.callCount).equals(0) + + throttled2() + + o(throttleMock.queueLength()).equals(2) + o(spy1.callCount).equals(0) + o(spy2.callCount).equals(0) + + throttleMock.fire() + + o(throttleMock.queueLength()).equals(0) + o(spy1.callCount).equals(1) + o(spy2.callCount).equals(1) + + throttleMock.fire() + + o(spy1.callCount).equals(1) + o(spy2.callCount).equals(1) + }) +}) diff --git a/test-utils/throttleMock.js b/test-utils/throttleMock.js new file mode 100644 index 00000000..6cdb5710 --- /dev/null +++ b/test-utils/throttleMock.js @@ -0,0 +1,27 @@ +"use strict" + +module.exports = function() { + var queue = [] + return { + throttle: function(fn) { + var pending = false + return function() { + if (!pending) { + queue.push(function(){ + pending = false + fn() + }) + pending = true + } + } + }, + fire: function() { + var tasks = queue + queue = [] + tasks.forEach(function(fn) {fn()}) + }, + queueLength: function(){ + return queue.length + } + } +} diff --git a/tests/test-api.js b/tests/test-api.js index 3240141c..49938d82 100644 --- a/tests/test-api.js +++ b/tests/test-api.js @@ -163,14 +163,24 @@ o.spec("api", function() { var count = 0 var root = window.document.createElement("div") m.mount(root, createComponent({view: function() {count++}})) + o(count).equals(1) + m.redraw() + o(count).equals(1) setTimeout(function() { - m.redraw() o(count).equals(2) done() }, FRAME_BUDGET) }) + o("sync", function() { + var root = window.document.createElement("div") + var view = o.spy() + m.mount(root, createComponent({view: view})) + o(view.callCount).equals(1) + m.redraw.sync() + o(view.callCount).equals(2) + }) }) }) })