From 584373bf33dfe06413650b01350f7b0e94493a9f Mon Sep 17 00:00:00 2001 From: LeXofLeviafan Date: Mon, 11 Apr 2022 01:59:47 +0300 Subject: [PATCH] optimized rendering & implemented automatic redraw cutoff & improved re-frame keys handling --- .gitignore | 1 + .npmignore | 1 + Cakefile | 10 +- README.md | 95 ++++- dist/mreframe-nodeps.js | 432 ++++++++++++++------- dist/mreframe-nodeps.min.js | 2 +- dist/mreframe.js | 432 ++++++++++++++------- dist/mreframe.min.js | 2 +- docs/re-frame.md | 9 +- docs/reagent.md | 21 +- docs/util.md | 18 +- examples/reagent.js.html | 125 ++++--- package.json | 7 +- performance/mithril.html | 9 + performance/mreframe.html | 10 + performance/test-perf.mithril.js | 604 ++++++++++++++++++++++++++++++ performance/test-perf.mreframe.js | 550 +++++++++++++++++++++++++++ src/re-frame.coffee | 72 ++-- src/reagent.coffee | 158 +++++--- src/util.coffee | 11 +- test/all.coffee | 2 +- test/re-frame.coffee | 94 +++-- test/reagent.coffee | 178 +++++---- test/redraw-detection.coffee | 262 +++++++++++++ test/util.coffee | 50 ++- yarn.lock | 18 + 26 files changed, 2625 insertions(+), 548 deletions(-) create mode 100644 performance/mithril.html create mode 100644 performance/mreframe.html create mode 100644 performance/test-perf.mithril.js create mode 100644 performance/test-perf.mreframe.js create mode 100644 test/redraw-detection.coffee diff --git a/.gitignore b/.gitignore index 22b80de..734a26f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /node_modules/ *.js !/dist/* +!/performance/* diff --git a/.npmignore b/.npmignore index c7aebcd..3b44270 100644 --- a/.npmignore +++ b/.npmignore @@ -1,6 +1,7 @@ /node_modules/ *.js !/dist/* +/performance/ /examples/ /docs/ /test/ diff --git a/Cakefile b/Cakefile index f8e4fbe..3fbc718 100644 --- a/Cakefile +++ b/Cakefile @@ -1,4 +1,4 @@ -[fs, {dirname}, CoffeeScript] = ['fs', 'path', 'coffeescript'].map require +[fs, {dirname}, {spawnSync}, CoffeeScript] = ['fs', 'path', 'child_process', 'coffeescript'].map require modules = ['util', 'atom', 'reagent', 're-frame'] deps = "mithril/mount,mithril/render,mithril/redraw,mithril/hyperscript" @@ -45,3 +45,11 @@ task 'build:all', "build all bundles (regular and minified)", -> task 'clean', "clean build/transpilation output", -> require('rimraf').sync s for s in ["*.js", "src/*.js", "examples/*.js", "dist/"] + +task 'test', "run unit tests", -> + spawnSync 'coffee', ["test/all.coffee"], stdio: 'inherit' + +task 'perftest', "run performance tests", -> + for test in ['mithril', 'mreframe'] + console.log "\t#{test}" + spawnSync 'node', ["performance/test-perf.#{test}.js"], stdio: 'inherit' diff --git a/README.md b/README.md index 89656dc..a38659f 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,47 @@ [`re-frame`](https://day8.github.io/re-frame) libraries from [ClojureScript](https://clojurescript.org); it's a mini-framework for single-page apps (using Mithril as the base renderer, with some interop). -Install: `npm i mreframe` or ``. +* _Lightweight_, both in size and use: just load a small JavaScript file, `require` it as a library, and you're good to go +* _No language/stack requirement_ – you can use JS directly, or any language that transpiles into it as long as it has interop +* _Simple, data-centric API_ using native JS data structures and plain functions for rendering, event handling, and querying state +* Components, events and queries have _no need to expose their inner workings_ beyond the level of a simple function call + +Install: `npm i mreframe`/`yarn add mreframe` or ``. + +Here's a full app code example: + +```js +let {reagent: r, reFrame: rf} = require('mreframe'); + +// registering events +rf.regEventDb('init', () => ({counter: 0})); // initial app state +rf.regEventDb('counter-add', (db, [_, n]) => ({...db, counter: db.counter + n})); + +// registering state queries +rf.regSub('counter', db => db.counter); + +// component functions +let IncButton = (n, caption) => + ['button', {onclick: () => rf.dispatch(['counter-add', n])}, // invoking counter-add event on button click + caption]; + +let Counter = () => + ['main', + ['span.counter', rf.dsub(['counter'])], // accessing app state + " ", + [IncButton, +1, "increment"]]; + +// initializing the app +rf.dispatchSync(['init']); // app state needs to be initialized immediately, before first render +r.render([Counter], document.body); +``` + +Tutorial / live demo: [Reagent (components)](https://lexofleviafan.github.io/mreframe/reagent.html), +[re-frame (events/state management)](https://lexofleviafan.github.io/mreframe/re-frame.html). * [Intro](#intro) * [Usage](#usage) +* [Q & A](#q--a) * [Examples](#examples) * [API reference](#api-reference) @@ -23,7 +60,7 @@ I've decided to make it a regular JS library instead (since Wisp would interop w To minimize dependencies (and thus keep the library lightweight as well, as well as make it easy to use), `mreframe` uses [Mithril](https://mithril.js.org) in place of React; it also has no other runtime dependencies. In current version, it has size -of 8Kb (3.5Kb gzipped) by itself, and when including required Mithril modules it merely goes up to 24Kb (9Kb gzipped). +of 10Kb (4Kb gzipped) by itself, and with required Mithril submodules included it merely goes up to 26Kb (9.5Kb gzipped). The library includes two main modules: [`reagent`](docs/reagent.md) (function components modelling DOM with data literals), and [`re-frame`](docs/re-frame.md) (state/side-effects management). You can decide to only use one of these as they're mostly @@ -35,7 +72,8 @@ Both `reagent` and `re-frame` were implemented mostly based on their [`reagent.core`](http://reagent-project.github.io/docs/master/reagent.core.html) and [`re-frame.core`](https://day8.github.io/re-frame/api-intro) APIs respectively, with minor changes to account for the switch from ClojureScript to JS and from React to Mithril. The most major change would be that since Mithril relies on minimizing -calculations rather than keeping track of dependency changes, state atoms in `mreframe` don't support subscription mechanisms; +calculations rather than keeping track of dependency changes, state atoms in `mreframe` don't support subscription mechanisms +(they do however register themselves with the current component and its ancestors to enable re-rendering detection); also, I omitted a few things like global interceptors and post-event callbacks from `re-frame` module, and added a couple helper functions to make it easier to use in JS. And, of course, in cases where switching to camelCase would make an identifier more convenient to use in JS, I did so. @@ -52,12 +90,12 @@ or, import as a script in webpage from a CDN: ` diff --git a/package.json b/package.json index ff55bcc..75f51ed 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "name": "mreframe", - "version": "0.0.6", + "version": "0.1.0", "description": "A reagent/re-frame imitation that uses Mithril instead", "main": "index.js", "author": "LeXofLeviafan ", + "homepage": "https://lexofleviafan.github.io/mreframe", "license": "MIT", "scripts": { "install": "cake transpile", @@ -14,13 +15,15 @@ "build-min:nodeps": "cake transpile && cake --minify build:nodeps", "build:all": "cake transpile && cake build:all", "clean": "cake clean", - "test": "coffee test/all.coffee" + "test": "cake test", + "perftest": "cake perftest" }, "dependencies": { "coffeescript": "^2.6.1", "mithril": "^2.0.4" }, "devDependencies": { + "benchmark": "^2.1.4", "browserify": "^17.0.0", "ospec": "^4.1.1", "rimraf": "^3.0.2", diff --git a/performance/mithril.html b/performance/mithril.html new file mode 100644 index 0000000..d878e70 --- /dev/null +++ b/performance/mithril.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/performance/mreframe.html b/performance/mreframe.html new file mode 100644 index 0000000..817d0ab --- /dev/null +++ b/performance/mreframe.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/performance/test-perf.mithril.js b/performance/test-perf.mithril.js new file mode 100644 index 0000000..5ed6eda --- /dev/null +++ b/performance/test-perf.mithril.js @@ -0,0 +1,604 @@ +"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. +*/ + +// Note: this tests against the generated bundle in browsers, but it tests +// against `index.js` in Node. Please do keep that in mind while testing. +// +// Mithril.js and Benchmark.js are loaded globally via bundle in the browser, so +// this doesn't require a CommonJS sham polyfill. + +// I add it globally just so it's visible in the tests. +/* global m, rootElem: true */ + +// set up browser env on before running tests +var isDOM = typeof window !== "undefined" +var Benchmark + +if (isDOM) { + Benchmark = window.Benchmark + window.rootElem = null +} else { + /* eslint-disable global-require */ + global.window = require('../node_modules/mithril/test-utils/browserMock')() + global.document = window.document + // We're benchmarking renders, not our throttling. + global.requestAnimationFrame = function () { + throw new Error("This should never be called.") + } + global.m = require('../node_modules/mithril/index.js') + global.rootElem = null + Benchmark = require('benchmark') + /* eslint-enable global-require */ +} + +function cycleRoot() { + if (rootElem) document.body.removeChild(rootElem) + document.body.appendChild(rootElem = document.createElement("div")) +} + +// Initialize benchmark suite +Benchmark.options.async = true +Benchmark.options.initCount = 10 +Benchmark.options.minSamples = 40 + +if (isDOM) { + // Wait long enough for the browser to actually commit the DOM changes to + // the screen before moving on to the next cycle, so things are at least + // reasonably fresh each cycle. + Benchmark.options.delay = 1 / 30 /* frames per second */ +} + +var suite = new Benchmark.Suite("Mithril.js perf", { + onStart: function () { + this.start = Date.now() + }, + + onCycle: function (e) { + console.log(e.target.toString()) + cycleRoot() + }, + + onComplete: function () { + console.log("Completed perf tests in " + (Date.now() - this.start) + "ms") + }, + + onError: function (e) { + console.error(e) + }, +}) +// eslint-disable-next-line no-unused-vars +var xsuite = {add: function(name) { console.log("skipping " + name) }} + +suite.add("construct large vnode tree", { + setup: function () { + this.fields = [] + + for(var i=100; i--;) { + this.fields.push((i * 999).toString(36)) + } + }, + fn: function () { + m(".foo.bar[data-foo=bar]", {p: 2}, + m("header", + m("h1.asdf", "a ", "b", " c ", 0, " d"), + m("nav", + m("a[href=/foo]", "Foo"), + m("a[href=/bar]", "Bar") + ) + ), + m("main", + m("form", + {onSubmit: function () {}}, + m("input[type=checkbox][checked]"), + 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("rerender identical vnode", { + setup: function () { + this.cached = m(".foo.bar[data-foo=bar]", {p: 2}, + m("header", + m("h1.asdf", "a ", "b", " c ", 0, " d"), + m("nav", + m("a", {href: "/foo"}, "Foo"), + m("a", {href: "/bar"}, "Bar") + ) + ), + m("main", + m("form", {onSubmit: function () {}}, + 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(rootElem, this.cached) + }, +}) + +suite.add("rerender same tree", { + setup: function () { + this.app = { + view: () => m(".foo.bar[data-foo=bar]", {p: 2}, + m("header", + m("h1.asdf", "a ", "b", " c ", 0, " d"), + m("nav", + m("a", {href: "/foo"}, "Foo"), + m("a", {href: "/bar"}, "Bar") + ) + ), + m("main", + m("form", {onSubmit: function () {}}, + 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(rootElem, m(this.app)) + }, +}) + +suite.add("add large nested tree", { + setup: function () { + var fields = [] + + for(var i=100; i--;) { + fields.push((i * 999).toString(36)) + } + + var NestedHeader = { + view: function () { + return m("header", + m("h1.asdf", "a ", "b", " c ", 0, " d"), + m("nav", + m("a", {href: "/foo"}, "Foo"), + m("a", {href: "/bar"}, "Bar") + ) + ) + } + } + + var NestedForm = { + view: function () { + return m("form", {onSubmit: function () {}}, + m("input[type=checkbox][checked]"), + m("input[type=checkbox]", {checked: false}), + m("fieldset", + m("label", + m("input[type=radio][checked]") + ), + m("label", + m("input[type=radio]") + ) + ), + m("fieldset", + fields.map(function (field) { + return m("label", + field, + ":", + m("input", {placeholder: field}) + ) + }) + ), + m(NestedButtonBar, null) + ) + } + } + + var NestedButtonBar = { + view: function () { + return m(".button-bar", + m(NestedButton, + {style: "width:10px; height:10px; border:1px solid #FFF;"}, + "Normal CSS" + ), + m(NestedButton, + {style: "top:0 ; right: 20"}, + "Poor CSS" + ), + m(NestedButton, + {style: "invalid-prop:1;padding:1px;font:12px/1.1 arial,sans-serif;", icon: true}, + "Poorer CSS" + ), + m(NestedButton, + {style: {margin: 0, padding: "10px", overflow: "visible"}}, + "Object CSS" + ) + ) + } + } + + var NestedButton = { + view: function (vnode) { + return m("button", vnode.attrs, vnode.children) /* m.censor(vnode.attrs) */ + } + } + + var NestedMain = { + view: function () { + return m(NestedForm) + } + } + + this.NestedRoot = { + view: function () { + return m("div.foo.bar[data-foo=bar]", + {p: 2}, + m(NestedHeader), + m(NestedMain) + ) + } + } + }, + fn: function () { + m.render(rootElem, m(this.NestedRoot)) + }, +}) + +suite.add("add large nested tree [changes on redraw]", { + setup: function () { + var fields = [] + + for(var i=100; i--;) { + fields.push((i * 999).toString(36)) + } + + var NestedHeader = { + view: function () { + return m("header", + m("h1.asdf", "a ", "b", " c ", 0, " d"), + m("nav", + m("a", {href: "/foo"}, "Foo"), + m("a", {href: "/bar"}, "Bar") + ) + ) + } + } + + var NestedForm = { + view: function ({attrs: {counter}}) { + return m("form", {onSubmit: function () {}}, + m("input[type=checkbox][checked]"), + m("input[type=checkbox]", {checked: false}), + m("fieldset", + m("label", + m("input[type=radio][checked]") + ), + m("label", + m("input[type=radio]") + ) + ), + m("fieldset", + fields.map(function (field, i) { + return m(NestedField, {key: i, field, disabled: (counter % fields.length === i)}) + }) + ), + m(NestedButtonBar, {objectCss: {margin: 0, padding: "10px", overflow: "visible"}}) + ) + } + } + + var NestedField = { + view: function ({attrs: {field, disabled}}) { + return m("label", + field, + ":", + m("input", {placeholder: field, disabled}) + ) + } + } + + var NestedButtonBar = { + view: function ({attrs: {objectCss}}) { + return m(".button-bar", + m(NestedButton, + {style: "width:10px; height:10px; border:1px solid #FFF;"}, + "Normal CSS" + ), + m(NestedButton, + {style: "top:0 ; right: 20"}, + "Poor CSS" + ), + m(NestedButton, + {style: "invalid-prop:1;padding:1px;font:12px/1.1 arial,sans-serif;", icon: true}, + "Poorer CSS" + ), + m(NestedButton, + {style: objectCss}, + "Object CSS" + ) + ) + } + } + + var NestedButton = { + view: function (vnode) { + return m("button", vnode.attrs, vnode.children) /* m.censor(vnode.attrs) */ + } + } + + var NestedMain = { + view: function ({attrs: {counter}}) { + return m(NestedForm, {counter}) + } + } + + this.NestedRoot = { + view: function ({attrs: {counter}}) { + return m("div.foo.bar[data-foo=bar]", + {p: 2}, + m(NestedHeader), + m(NestedMain, {counter}) + ) + } + } + + this.counter = 0; + }, + fn: function () { + m.render(rootElem, m(this.NestedRoot, {counter: this.counter})) + this.counter++; + }, +}) + +suite.add("mutate styles/properties", { + setup: function () { + function get(obj, i) { return obj[i % obj.length] } + var counter = 0 + var classes = ["foo", "foo bar", "", "baz-bat", null, "fooga", null, null, undefined] + var styles = [] + var multivalue = ["0 1px", "0 0 1px 0", "0", "1px", "20px 10px", "7em 5px", "1px 0 5em 2px"] + var stylekeys = [ + ["left", function (c) { return c % 3 ? c + "px" : c }], + ["top", function (c) { return c % 2 ? c + "px" : c }], + ["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', ...this.fields.map(field => + ['label', + field, ":", + ['input', {placeholder: field}]])]], + ['button-bar', + ['button', {style: "width:10px; height:10px; border:1px solid #FFF;"}, + "Normal CSS"], + ['button', {style: "top:0 ; right: 20"}, + "Poor CSS"], + ['button', {style: "invalid-prop:1;padding:1px;font:12px/1.1 arial,sans-serif;", icon: true}, + "Poorer CSS"], + ['button', {style: {margin: 0, padding: "10px", overflow: 'visible'}}, + "Object CSS"]]]]]); + }, +}); + +/*suite.add("construct large nested tree", { + setup () { + let fields = []; + + for (let i = 100; i--;) + fields.push((i * 999).toString(36)); + + let NestedButton = (attrs, ...children) => + ['button', attrs, ['<>', ...children]]; //m.censor(attrs) + + let NestedButtonBar = () => + ['.button-bar', + [NestedButton, {style: "width:10px; height:10px; border:1px solid #FFF;"}, + "Normal CSS"], + [NestedButton, {style: "top:0 ; right: 20"}, + "Poor CSS"], + [NestedButton, {style: "invalid-prop:1;padding:1px;font:12px/1.1 arial,sans-serif;", icon: true}, + "Poorer CSS"], + [NestedButton, {style: {margin: 0, padding: "10px", overflow: "visible"}}, + "Object CSS"]]; + + let NestedForm = () => + ['form', {onSubmit () {}}, + ['input[type=checkbox][checked]'], + ['input[type=checkbox]', {checked: false}], + ['fieldset', + ['label', + ['input[type=radio][checked]']], + ['label', + ['input[type=radio]']]], + ['fieldset', + ...fields.map(field => + ['label', + field, ":", + ['input', {placeholder: field}]])], + [NestedButtonBar]]; + + let NestedMain = () => [NestedForm]; + + let NestedHeader = () => + ['header', + ['h1.asdf', "a ", "b", " c ", 0, " d"], + ['nav', {class: ['foo', 'bar', 0 && 'baz']}, + ['a', {href: '/foo'}, "Foo"], + ['a', {href: '/bar'}, "Bar"]]]; + + this.NestedRoot = () => + ['div.foo.bar[data-foo=bar]', {p: 2}, + [NestedHeader], + [NestedMain]]; + }, + + fn () { + r.asElement([this.NestedRoot]); + }, +}); + +suite.add("construct large nested tree (classes)", { + setup () { + let fields = []; + + for (let i = 100; i--;) + fields.push((i * 999).toString(36)); + + let NestedButton = r.createClass({ + reagentRender: (attrs, ...children) => + ['button', attrs, ['<>', ...children]], //m.censor(attrs) + }); + + let NestedButtonBar = r.createClass({ + reagentRender: () => + ['.button-bar', + [NestedButton, {style: "width:10px; height:10px; border:1px solid #FFF;"}, + "Normal CSS"], + [NestedButton, {style: "top:0 ; right: 20"}, + "Poor CSS"], + [NestedButton, {style: "invalid-prop:1;padding:1px;font:12px/1.1 arial,sans-serif;", icon: true}, + "Poorer CSS"], + [NestedButton, {style: {margin: 0, padding: "10px", overflow: "visible"}}, + "Object CSS"]], + }); + + let NestedForm = r.createClass({ + reagentRender: () => + ['form', {onSubmit () {}}, + ['input[type=checkbox][checked]'], + ['input[type=checkbox]', {checked: false}], + ['fieldset', + ['label', + ['input[type=radio][checked]']], + ['label', + ['input[type=radio]']]], + ['fieldset', + ...fields.map(field => + ['label', + field, ":", + ['input', {placeholder: field}]])], + [NestedButtonBar]], + }); + + let NestedMain = r.createClass({reagentRender: () => [NestedForm]}); + + let NestedHeader = r.createClass({ + reagentRender: () => + ['header', + ['h1.asdf', "a ", "b", " c ", 0, " d"], + ['nav', {class: ['foo', 'bar', 0 && 'baz']}, + ['a', {href: '/foo'}, "Foo"], + ['a', {href: '/bar'}, "Bar"]]], + }); + + this.NestedRoot = r.createClass({ + reagentRender: () => + ['div.foo.bar[data-foo=bar]', {p: 2}, + [NestedHeader], + [NestedMain]], + }); + }, + + fn () { + r.asElement([this.NestedRoot]); + }, +});*/ + +suite.add("rerender identical vnode", { + setup () { + this.cached = r.asElement(['.foo.bar[data-foo=bar]', {p: 2}, + ['header', + ['h1.asdf', "a ", "b", " c ", 0, " d"], + ['nav', + ['a', {href: '/foo'}, "Foo"], + ['a', {href: '/bar'}, "Bar"]]], + ['main', + ['form', {onSubmit () {}}, + ['input', {type: 'checkbox', checked: true}], + ['input', {type: 'checkbox', checked: false}], + ['fieldset', + ['label', + ['input', {type: 'radio', checked: true}]], + ['label', + ['input', {type: 'radio'}]]], + ['button-bar', + ['button', {style: "width:10px; height:10px; border:1px solid #FFF;"}, + "Normal CSS"], + ['button', {style: "top:0 ; right: 20"}, + "Poor CSS"], + ['button', {style: "invalid-prop:1;padding:1px;font:12px/1.1 arial,sans-serif;", icon: true}, + "Poorer CSS"], + ['button', {style: {margin: 0, padding: "10px", overflow: 'visible'}}, + "Object CSS"]]]]]); + }, + + fn () { + m.render(rootElem, this.cached); + }, +}); + +suite.add("rerender same tree", { + setup () { + this.app = () => + ['.foo.bar[data-foo=bar]', {p: 2}, + ['header', + ['h1.asdf', "a ", "b", " c ", 0, " d"], + ['nav', + ['a', {href: "/foo"}, "Foo"], + ['a', {href: "/bar"}, "Bar"]]], + ['main', + ['form', {onSubmit () {}}, + ['input', {type: 'checkbox', checked: true}], + ['input', {type: 'checkbox', checked: false}], + ['fieldset', + ['label', + ['input', {type: 'radio', checked: true}]], + ['label', + ['input', {type: 'radio'}]]], + ['button-bar', + ['button', {style: "width:10px; height:10px; border:1px solid #FFF;"}, + "Normal CSS"], + ['button', {style: "top:0 ; right: 20"}, + "Poor CSS"], + ['button', {style: "invalid-prop:1;padding:1px;font:12px/1.1 arial,sans-serif;", icon: true}, + "Poorer CSS"], + ['button', {style: {margin: 0, padding: "10px", overflow: "visible"}}, + "Object CSS"]]]]] + }, + + fn () { + m.render(rootElem, r.asElement([this.app])); + }, +}); + +suite.add("add large nested tree", { + setup () { + let fields = []; + + for (let i = 100; i--;) + fields.push((i * 999).toString(36)) + + let NestedButton = (attrs, ...children) => + ['button', attrs, ['<>', ...children]]; // m.censor(attrs) + + let NestedButtonBar = () => + ['.button-bar', + [NestedButton, {style: "width:10px; height:10px; border:1px solid #FFF;"}, + "Normal CSS"], + [NestedButton, {style: "top:0 ; right: 20"}, + "Poor CSS"], + [NestedButton, {style: "invalid-prop:1;padding:1px;font:12px/1.1 arial,sans-serif;", icon: true}, + "Poorer CSS"], + [NestedButton, {style: {margin: 0, padding: "10px", overflow: "visible"}}, + "Object CSS"]]; + + let NestedForm = () => + ['form', {onSubmit () {}}, + ['input[type=checkbox][checked]'], + ['input[type=checkbox]', {checked: false}], + ['fieldset', + ['label', + ['input[type=radio][checked]']], + ['label', + ['input[type=radio]']]], + ['fieldset', + ...fields.map(field => + ['label', + field, ":", + ['input', {placeholder: field}]])], + [NestedButtonBar]]; + + let NestedMain = () => [NestedForm]; + + let NestedHeader = () => + ['header', + ['h1.asdf', "a ", "b", " c ", 0, " d"], + ['nav', + ['a', {href: '/foo'}, "Foo"], + ['a', {href: '/bar'}, "Bar"]]]; + + this.NestedRoot = () => + ['div.foo.bar[data-foo=bar]', {p: 2}, + [NestedHeader], + [NestedMain]]; + }, + + fn () { + m.render(rootElem, r.asElement([this.NestedRoot])); + }, +}); + +suite.add("add large nested tree [changes on redraw]", { + setup () { + let fields = []; + + for (let i = 100; i--;) + fields.push((i * 999).toString(36)) + + let NestedButton = (attrs, ...children) => + ['button', attrs, ['<>', ...children]]; // m.censor(attrs) + + let NestedButtonBar = objectCss => + ['.button-bar', + [NestedButton, {style: "width:10px; height:10px; border:1px solid #FFF;"}, + "Normal CSS"], + [NestedButton, {style: "top:0 ; right: 20"}, + "Poor CSS"], + [NestedButton, {style: "invalid-prop:1;padding:1px;font:12px/1.1 arial,sans-serif;", icon: true}, + "Poorer CSS"], + [NestedButton, {style: objectCss}, + "Object CSS"]]; + + let NestedField = (field, disabled) => + ['label', + field, ":", + ['input', {placeholder: field, disabled}]]; + + let NestedForm = n => + ['form', {onSubmit () {}}, + ['input[type=checkbox][checked]'], + ['input[type=checkbox]', {checked: false}], + ['fieldset', + ['label', + ['input[type=radio][checked]']], + ['label', + ['input[type=radio]']]], + ['fieldset', ...fields.map((field, i) => + r.with({key: i}, [NestedField, field, (n % fields.length === i)]))], + [NestedButtonBar, {margin: 0, padding: "10px", overflow: "visible"}]]; + + let NestedMain = n => [NestedForm, n]; + + let NestedHeader = () => + ['header', + ['h1.asdf', "a ", "b", " c ", 0, " d"], + ['nav', + ['a', {href: '/foo'}, "Foo"], + ['a', {href: '/bar'}, "Bar"]]]; + + this.counter = 0; + this.NestedRoot = n => + ['div.foo.bar[data-foo=bar]', {p: 2}, + [NestedHeader, n], + [NestedMain]]; + }, + + fn () { + m.render(rootElem, r.asElement([this.NestedRoot, this.counter])); + this.counter++; + }, +}); + +suite.add("mutate styles/properties", { + setup () { + let get = (obj, i) => obj[i % obj.length]; + let classes = ["foo", "foo bar", "", "baz-bat", null, "fooga", null, null, undefined]; + let styles = []; + let multivalue = ["0 1px", "0 0 1px 0", "0", "1px", "20px 10px", "7em 5px", "1px 0 5em 2px"]; + let stylekeys = [ + ['left', c => (c % 3 === 0 ? c : c + "px")], + ['top', c => (c % 2 === 0 ? c : c + "px")], + ['margin', c => get(multivalue, c).replace("1px", c + "px")], + ['padding', c => get(multivalue, c)], + ['position', c => (c % 5 === 0 ? null : c % 2 === 0 ? 'relative' : 'absolute')], + ['display', c => (c % 10 === 0 ? 'none' : c % 2 === 0 ? 'inline' : 'block')], + ['color', c => ("rgba(" + (c % 255) + ", " + (255 - c % 255) + ", " + (50 + c % 150) + ", " + (c % 50 / 50) + ")")], + ['border', c => (c % 5 === 0 ? "" : (c % 10) + "px " + (c % 2 === 0 ? 'dotted' : 'solid') + " " + stylekeys[6][1](c))], + ]; + let i, j, style, conf; + + let counter = 0; + for (let i = 0; i < 1000; i++) { + let style = {}; + for (let j = 0; j < i % 10; j++) { + let conf = get(stylekeys, ++counter); + style[ conf[0] ] = conf[1](counter); + } + styles[i] = style; + } + + this.count = 0; + this.app = () => { + let elems = ['<>']; + for (let index = ++this.count, last = index + 300; index < last; index++) + elems.push(['div.booga', {class: get(classes, index), 'data-index': index, title: index.toString(36)}, + ['input.dooga', {type: 'checkbox', checked: index % 3 === 0}], + ['input', {value: "test " + Math.floor(index / 4), disabled: (index % 10 ? null : true)}], + ['div', {class: get(classes, index * 11)}, + ['p', {style: get(styles, index)}, "p1"], + ['p', {style: get(styles, index + 1)}, "p2"], + ['p', {style: get(styles, index * 2)}, "p3"], + ['p.zooga', {style: get(styles, index * 3 + 1), className: get(classes, index * 7)}, "p4"]]]) + return elems; + }; + }, + + fn () { + m.render(rootElem, r.asElement([this.app, this.count])); + }, +}); + +suite.add("repeated add/removal", { + setup () { + let RepeatedButton = (attrs, ...children) => + ['button', attrs, ...children]; + + let RepeatedButtonBar = () => + ['.button-bar', + [RepeatedButton, {style: "width:10px; height:10px; border:1px solid #FFF;"}, + "Normal CSS"], + [RepeatedButton, {style: "top:0 ; right: 20"}, + "Poor CSS"], + [RepeatedButton, {style: "invalid-prop:1;padding:1px;font:12px/1.1 arial,sans-serif;", icon: true}, + "Poorer CSS"], + [RepeatedButton, {style: {margin: 0, padding: "10px", overflow: 'visible'}}, + "Object CSS"]]; + + let RepeatedForm = () => + ['form', {onSubmit () {}}, + ['input', {type: 'checkbox', checked: true}], + ['input', {type: 'checkbox', checked: false}], + ['fieldset', + ['label', + ['input', {type: 'radio', checked: true}]], + ['label', + ['input', {type: 'radio'}]]], + [RepeatedButtonBar]]; + + let RepeatedMain = () => [RepeatedForm]; + + let RepeatedHeader = () => + ['header', + ['h1.asdf', "a ", "b", " c ", 0, " d"], + ['nav', + ['a', {href: '/foo'}, "Foo"], + ['a', {href: '/bar'}, "Bar"]]]; + + this.RepeatedRoot = () => + ['div.foo.bar[data-foo=bar]', {p: 2}, + [RepeatedHeader], + [RepeatedMain]]; + }, + + fn () { + m.render(rootElem, [r.asElement([this.RepeatedRoot])]); + m.render(rootElem, []); + }, +}); + +if (isDOM) { + window.onload = function () { + cycleRoot() + suite.run() + } +} else { + cycleRoot() + suite.run() +} diff --git a/src/re-frame.coffee b/src/re-frame.coffee index 3b633dc..717dbba 100644 --- a/src/re-frame.coffee +++ b/src/re-frame.coffee @@ -1,5 +1,6 @@ -{eq, keys, dict, entries, isArray, isDict, isFn, getIn, merge, assoc, assocIn, dissoc, update, repr, identity, chunks, flatten, chain} = require './util' -{deref, reset, swap} = require './atom' +{identical, eq, eqShallow, keys, dict, entries, isArray, isDict, isFn, getIn, + merge, assoc, assocIn, dissoc, update, repr, identity, chunks, flatten, chain} = require './util' +{atom, deref, reset, swap} = require './atom' {_init: _initReagent, atom: ratom, cursor} = require './reagent' _eq_ = eq @@ -10,10 +11,13 @@ exports._init = (opts) => ### Application state atom ### exports.appDb = appDb = ratom {} -events = ratom {} -effects = ratom {} -coeffects = ratom {} -subscriptions = ratom {} +events = atom {} +effects = atom {} +coeffects = atom {} +subscriptions = atom {} + +_noHandler = (kind, [key]) => console.error "re-frame: no #{kind} handler registered for: '#{key}'" +_duplicate = (kind, key) => console.warn "re-frame: overwriting #{kind} handler for: '#{key}'" _subscriptionCache = new Map ### Removes cached subscriptions (forcing to recalculate) ### @@ -27,17 +31,19 @@ _clear = (atom) => (id) => if id then swap atom, dissoc, id else reset atom, {} undefined -_invalidSignals = => throw SyntaxError "Invalid subscription signals" +_invalidSignals = => throw SyntaxError "re-frame: invalid subscription signals" _signals = (signals) => _invalidSignals() unless signals.every ([k, q]) => (k is '<-') and isArray q queries = signals.map (kq) => kq[1] - if queries.length is 1 then (=> subscribe queries[0]) else (=> queries.map subscribe) + if queries.length is 1 then (=> subscribe queries[0]) else (=> queries.map subscribe) + +_deref = (ratom) => ratom._deref() # parent ratom is not to be propagated _calcSignals = (signals) => - if isArray signals then signals.map deref - else unless isDict signals then deref signals - else dict entries(signals).map ([k, v]) => [k, deref v] + if isArray signals then signals.map _deref + else unless isDict signals then _deref signals + else dict entries(signals).map ([k, v]) => [k, _deref v] ### Registers a subscription function to compute view data ### exports.regSub = (id, ...signals, computation) => @@ -46,25 +52,29 @@ exports.regSub = (id, ...signals, computation) => else if signals.length isnt 1 then _signals (chunks signals, 2) else if isFn signals[0] then signals[0] else _invalidSignals() + _duplicate "subscription", id if (deref subscriptions)[id] swap subscriptions, assoc, id, [signals, computation] undefined _calcSub = (signals, computation) => (query) => - key = repr query input = _calcSignals signals query - if _subscriptionCache.has key + if _subscriptionCache.has(key = repr query) [input_, output] = _subscriptionCache.get key - return output if _eq_ input, input_ + return output if eqShallow input, input_ x = computation input, query _subscriptionCache.set key, [input, x] x +_cursors = new Map ### Returns an RCursor that derefs to subscription result (or cached value) ### exports.subscribe = subscribe = (query) => - cursor _calcSub( ...deref(subscriptions)[ query[0] ] ), query + unless (it = (deref subscriptions)[ query[0] ]) then _noHandler "subscription", query else + _cursors.set key, cursor _calcSub(...it), query unless _cursors.has(key = repr query) + _cursors.get key ### Unregisters one or all subscription functions ### -exports.clearSub = _clear subscriptions +exports.clearSub = do (_clearSubs = _clear subscriptions) => + (id) => id or _cursors.clear(); _clearSubs id ### Produces an interceptor (changed from varargs to options object). @@ -133,15 +143,20 @@ exports.onChanges = (f, outPath, ...inPaths) => toInterceptor after: (context) => db0 = getCoeffect(context, 'db'); db1 = _getDb context [ins, outs] = [db0, db1].map (db) => inPaths.map (path) => getIn db, path - unless (outs.some (x, i) => x isnt ins[i]) then context else + if (outs.every (x, i) => identical x, ins[i]) then context else assocEffect context, 'db', (assocIn db1, outPath, f ...outs) ### Registers a coeffect handler (for use as an interceptor) ### -exports.regCofx = (id, handler) => swap coeffects, assoc, id, handler; undefined +exports.regCofx = (id, handler) => + _duplicate "coeffect", id if (deref coeffects)[id] + swap coeffects, assoc, id, handler + undefined ### Produces an interceptor which applies a coeffect handler before the event handler ### -exports.injectCofx = (key, arg) => - toInterceptor id: key, before: (context) => update context, 'coeffects', (deref coeffects)[key], arg +exports.injectCofx = (key, arg) => toInterceptor id: key, before: (context) => + if (it = (deref coeffects)[key]) + update context, 'coeffects', (deref coeffects)[key], arg + else _noHandler "coeffect", [key]; context ### Unregisters one or all coeffect handlers ### exports.clearCofx = _clear coeffects @@ -162,6 +177,7 @@ exports.regEventFx = regEventFx = (id, interceptors, handler) => ### Registers an event handler which arbitrarily updates the context ### exports.regEventCtx = regEventCtx = (id, interceptors, handler) => [interceptors, handler] = [[], interceptors] unless handler + _duplicate "event", id if (deref events)[id] swap events, assoc, id, [(flatten interceptors.filter identity), handler] undefined @@ -186,9 +202,9 @@ _intercept = (context, hook) => # every step is dynamic so no chains, folds or f context ### Dispatches an event (running back and forth through interceptor chain & handler then actions effects) ### -exports.dispatchSync = dispatchSync = (event) => - [stack, handler] = deref(events)[ event[0] ] - context = {stack, coeffects: {event, db: deref appDb}} +exports.dispatchSync = dispatchSync = (event) => unless (it = (deref events)[ event[0] ]) then _noHandler "event", event else + [stack, handler] = it + context = {stack, coeffects: {event, db: _deref appDb}} chain context, [_intercept, 'before'], handler, [_intercept, 'after'], getEffect, entries, _fx _dispatch = ({ms, dispatch}) => @@ -197,15 +213,19 @@ _dispatch = ({ms, dispatch}) => ### Schedules dispatching of an event ### exports.dispatch = dispatch = (dispatch) => _dispatch {dispatch} -_fx = (fxs, fx=deref effects) => fxs.filter(identity).forEach ([k, v]) => (fx[k] or _effects[k]) v +_fx = (fxs, fx=deref effects) => fxs.filter(identity).forEach ([k, v]) => + if (it = fx[k] or _effects[k]) then it v else _noHandler "effect", [k] _effects = # builtin effects - db: (value) => reset appDb, value + db: (value) => reset appDb, value unless _eq_ value, _deref appDb fx: _fx dispatchLater: _dispatch dispatch: (dispatch) => _dispatch {dispatch} ### Registers an effect handler (implementation of a side-effect) ### -exports.regFx = (id, handler) => swap effects, assoc, id, handler; undefined +exports.regFx = (id, handler) => + _duplicate "effect", id if (deref effects)[id] + swap effects, assoc, id, handler + undefined ### Unregisters one or all effect handlers (excepting builtin ones) ### exports.clearFx = _clear effects diff --git a/src/reagent.coffee b/src/reagent.coffee index 41b4561..ea598c0 100644 --- a/src/reagent.coffee +++ b/src/reagent.coffee @@ -1,47 +1,74 @@ -{eq, type, isArray, isDict, isFn, keys, getIn, merge, assoc, assocIn, identity} = require './util' +{identical, eqShallow, isArray, keys, getIn, merge, assocIn, identity} = require './util' {atom, deref, reset, swap} = require './atom' _mount_ = _redraw_ = _mithril_ = identity _fragment_ = second = (a, b) => b -_eq_ = eq exports._init = (opts) => _mithril_ = opts?.hyperscript || _mithril_ _fragment_ = _mithril_.fragment || second _redraw_ = opts?.redraw || _redraw_ _mount_ = opts?.mount || _mount_ - _eq_ = opts?.eq || _eq_ undefined -_vnode = atom() # contains vnode of most recent component +_vnode = null # contains vnode of most recent component _renderCache = new Map ### Reset function components cache. ### exports.resetCache = => _renderCache.clear() +_propagate = (vnode, ratom, value) => + while vnode + vnode.state._subs.set ratom, value + vnode = vnode._parent + value + +_eqArgs = (xs, ys) => (not xs and not ys) or (xs?.length is ys?.length and xs.every (x, i) => eqShallow x, ys[i]) + +_detectChanges = (vnode) -> not _eqArgs(vnode.attrs.argv, @_argv) or # arguments changed? + ((subs = Array.from(@_subs)).some ([ratom, value]) => ratom._deref() isnt value) or # ratoms changed? + (subs.forEach(([ratom, value]) => _propagate vnode._parent, ratom, value); no) # no changes, propagating ratoms + +_rendering = (binding) => (vnode) -> + _vnode = vnode + try + @_subs.clear() + @_argv = vnode.attrs.argv # last render args + binding.call @, vnode + finally + _vnode = null + _fnElement = (fcomponent) => unless _renderCache.has fcomponent component = - oninit: (vnode) => - vnode.state._comp = component - vnode.state._atom = ratom() + oninit: (vnode) -> + @_comp = component # self + @_subs = new Map # input ratoms (resets before render) + @_atom = ratom() # state ratom; ._subs should work for it as well + @_view = fcomponent undefined - view: (vnode) => - reset _vnode, vnode - args = (argv vnode)[1..] - x = (vnode.state._view or fcomponent) ...args - asElement unless isFn x then x else vnode.state._view = x; x ...args + onbeforeupdate: _detectChanges + view: _rendering (vnode) -> + x = @_view.apply vnode, (args = vnode.attrs.argv[1..]) + asElement if typeof x isnt 'function' then x else (@_view = x).apply vnode, args _renderCache.set fcomponent, component _renderCache.get fcomponent -_meta = (meta, args) => if isDict args[0] then [merge(args[0], meta), ...args[1..]] else [meta, ...args] +_meta = (meta, o) => if typeof o is 'object' and not isArray o then [merge o, meta] else [meta, asElement o] +_moveParent = (vnode) => + if vnode.attrs + vnode._parent = vnode.attrs._parent or null # might be undefined if not called directly from a component + delete vnode.attrs._parent + vnode ### Converts Hiccup forms into Mithril vnodes ### -exports.asElement = asElement = (form) => unless isArray form then form else - [head, ...tail] = form - meta = form._meta or {} - if head is '>' then createElement tail[0], ...(_meta meta, tail[1..]).map asElement - else if head is '<>' then _fragment_ meta, tail.map asElement - else if type(head) is String then createElement head, ...(_meta meta, tail).map asElement - else if isFn head then createElement (_fnElement head), (merge meta, argv: form) - else createElement head, (merge meta, argv: form) +exports.asElement = asElement = (form) => + if isArray form + head = form[0] + meta = {...(form._meta or {}), _parent: _vnode} + if head is '>' then _createElement form[1], (_meta meta, form[2]), form[3..].map asElement + else if head is '<>' then _moveParent _fragment_ meta, form[1..].map asElement + else if typeof head is 'string' then _createElement head, (_meta meta, form[1]), form[2..].map asElement + else if typeof head is 'function' then _createElement (_fnElement head), [{...meta, argv: form}] + else _createElement head, [{...meta, argv: form}] + else form ### Mounts a Hiccup form to a DOM element ### exports.render = (comp, container) => _mount_ container, view: => asElement comp @@ -56,27 +83,31 @@ exports.with = (meta, form) => form = form[..]; form._meta = meta; form componentWillUnmount, shouldComponentUpdate, render, reagentRender (use symbols in Wisp). Also, beforeComponentUnmounts was added (see 'onbeforeremove' in Mithril). Instead of 'this', vnode is passed in calls. + NOTE: shouldComponentUpdate overrides Reagent changes detection ### exports.createClass = (spec) => - call = (k, vnode, args) => if spec[k] - reset _vnode, vnode - spec[k].apply vnode, args or [vnode] + bind = (k, method=spec[k]) => method and ((vnode, args) => + _vnode = vnode + try method.apply vnode, args or [vnode] + finally _vnode = null) component = - oninit: (vnode) => - vnode.state._comp = component - vnode.state._atom = ratom call 'getInitialState', vnode - call 'constructor', vnode, [vnode, props vnode] - oncreate: (vnode) => call 'componentDidMount', vnode - onupdate: (vnode) => call 'componentDidUpdate', vnode - onremove: (vnode) => call 'componentWillUnmount', vnode - onbeforeupdate: (vnode) => call 'shouldComponentUpdate', vnode - onbeforeremove: (vnode) => call 'beforeComponentUnmounts', vnode - view: (vnode) => if spec.render then call 'render', vnode else - asElement call('reagentRender', vnode, (argv vnode)[1..]) - -RAtom = (@x) -> -deref.when RAtom, (self) => self.x -reset.when RAtom, (self, value) => if _eq_ value, self.x then self.x else + oninit: (vnode) -> + @_comp = component + @_subs = new Map + @_atom = ratom bind('getInitialState')? vnode + bind('constructor')? vnode, [vnode, vnode.attrs] + undefined + oncreate: bind 'componentDidMount' + onupdate: bind 'componentDidUpdate' + onremove: bind 'componentWillUnmount' + onbeforeupdate: bind('shouldComponentUpdate') or _detectChanges + onbeforeremove: bind 'beforeComponentUnmounts' + view: _rendering(spec.render or do (render = spec.reagentRender) => + (vnode) -> asElement render.apply vnode, vnode.attrs.argv[1..]) # component + +RAtom = (@x) -> @_deref = (=> @x); undefined # ._deref doesn't cause propagation +deref.when RAtom, (self) => _propagate _vnode, self, self._deref() +reset.when RAtom, (self, value) => if identical value, self.x then value else self.x = value _redraw_() value @@ -84,19 +115,18 @@ reset.when RAtom, (self, value) => if _eq_ value, self.x then self.x else ### Produces an atom which causes redraws on update ### exports.atom = ratom = (x) => new RAtom x -None = {} -_cursor = (ratom) => (path, value=None) => - if value is None then getIn (deref ratom), path else swap ratom, assocIn, path, value - -RCursor = (@src, @path) -> -deref.when RCursor, (self) => self.src self.path -reset.when RCursor, (self, value) => if _eq_ value, (x = deref self) then x else +RCursor = (@src, @path) -> @_deref = (=> @src @path); undefined +deref.when RCursor, (self) => _propagate _vnode, self, self._deref() +reset.when RCursor, (self, value) => if identical value, self._deref() then value else self.src self.path, value _redraw_() value -### Produces a cursor (sub-state atom) from a path and either an atom or a getter/setter function ### -exports.cursor = (src, path) => new RCursor (if isFn src then src else _cursor src), path +_cursor = (ratom) => (path, value) => # value is optional but undefined would be replaced with fallback value anyway + if value is undefined then getIn ratom._deref(), path else swap ratom, assocIn, path, value + +### Produces a cursor (sub-state atom) from a path and either a r.atom or a getter/setter function ### +exports.cursor = (src, path) => new RCursor (if typeof src is 'function' then src else _cursor src), path ### Converts a Mithril component into a Reagent component ### exports.adaptComponent = (c) => (...args) => ['>', c, ...args] @@ -104,21 +134,27 @@ exports.adaptComponent = (c) => (...args) => ['>', c, ...args] ### Merges provided class definitions into a string (definitions can be strings, lists or dicts) ### exports.classNames = classNames = (...classes) => cls = classes.reduce ((o, x) => - x = "#{x}".split ' ' unless isArray(x) or isDict(x) - merge o, (if isDict x then x else merge ...x.map (k) => k and [k]: k) + x = "#{x}".split ' ' unless typeof x is 'object' + merge o, (unless isArray x then x else merge ...x.map (k) => k and [k]: k) ), {} - (keys cls).map((k) => cls[k] and k).filter(identity).join ' ' - -calcCssClass = (props) => - ['class', 'className', 'classList'].reduce ((o, k) => assoc o, k, o[k] and classNames o[k]), props - -### Invokes Mithril directly to produce a vnode (props are optional) ### -exports.createElement = createElement = (type, ...children) => - [props, ...children] = if isDict children[0] then children else [{}, ...children] - _mithril_ type, (calcCssClass props), ...children + (keys cls).filter((k) => cls[k]).join ' ' + +_quiet = (handler) => if typeof handler isnt 'function' then handler else (event) -> event.redraw = no; handler.call @, event +_quietEvents = (attrs, o = {}) => + (o[k] = if k[..1] isnt 'on' then v else _quiet v) for k, v of attrs + o +prepareAttrs = (tag, props) => if typeof tag isnt 'string' then props else + ['class', 'className', 'classList'].reduce ((o, k) => o[k] and o[k] = classNames o[k]; o), _quietEvents props + +_createElement = (type, first, rest) => # performance optimization + _rest = if first[1]?.attrs?.key? then rest else [rest] + _moveParent _mithril_ type, (prepareAttrs type, first[0]), first[1], ..._rest +### Invokes Mithril directly to produce a vnode (props are optional if no children are given) ### +exports.createElement = (type, props, ...children) => + _createElement type, [props or {}], children ### Produces the vnode of current (most recent?) component ### -exports.currentComponent = => deref _vnode +exports.currentComponent = => _vnode ### Returns children of the Mithril vnode ### exports.children = children = (vnode) => vnode.children @@ -127,7 +163,7 @@ exports.children = children = (vnode) => vnode.children exports.props = props = (vnode) => vnode.attrs ### Produces the Hiccup form of the Reagent component from vnode ### -exports.argv = argv = (vnode) => (props vnode).argv +exports.argv = argv = (vnode) => vnode.attrs.argv ### Returns RAtom containing state of a Reagent component (from vnode) ### exports.stateAtom = stateAtom = (vnode) => vnode.state._atom diff --git a/src/util.coffee b/src/util.coffee index 681a080..71a5f4a 100644 --- a/src/util.coffee +++ b/src/util.coffee @@ -24,10 +24,15 @@ exports.chunks = (xs, n) => Array.from length: (Math.ceil xs.length / n), (_, i) => xs[n*i ... n*(i+1)] # / exports.flatten = flatten = (xs) => unless isArray xs then xs else xs.flatMap flatten exports.repr = (x) => JSON.stringify x, replacer +exports.identical = identical = (a, b) => a is b or (a isnt a and b isnt b) exports.eq = eq = (a, b) => a is b or if a isnt a then b isnt b - else if isArray a then (isArray b) and eqArr a, b + else if isArray a then (isArray b) and eqArr a, b, eq else (isDict a) and (isDict b) and eqObj a, b +exports.eqShallow = eqShallow = (a, b) => a is b or + if a isnt a then b isnt b + else if isArray a then (isArray b) and eqArr a, b, identical + else (isDict a) and (isDict b) and eqObjShallow a, b sorter = (o) => _dict (entries o).sort() @@ -36,9 +41,11 @@ replacer = (_, v) => else unless isDict v then v else sorter v -eqArr = (xs, ys) => xs.length is ys.length and xs.every (x, i) => eq x, ys[i] +eqArr = (xs, ys, eq) => xs.length is ys.length and xs.every (x, i) => eq x, ys[i] eqObj = (a, b, aks = (keys a), bks = new Set(keys b)) => aks.length is bks.size and aks.every((k) => bks.has(k)) and aks.every (k) => eq a[k], b[k] +eqObjShallow = (a, b, aks = (keys a)) => + aks.length is keys(b).length and aks.every (k) => k of b and identical a[k], b[k] exports.chain = (x, ...fs) => fs.map((f) => if isArray f then f else [f]).reduce ((x, f) => f[0] x, ...f[1..]), x diff --git a/test/all.coffee b/test/all.coffee index 6e83516..6ae1f7e 100644 --- a/test/all.coffee +++ b/test/all.coffee @@ -1,3 +1,3 @@ -['coffeescript/register', './util', './atom', './reagent', './re-frame'].forEach require +['coffeescript/register', './util', './atom', './reagent', './re-frame', './redraw-detection'].forEach require (require 'ospec').run() diff --git a/test/re-frame.coffee b/test/re-frame.coffee index abee8c0..2b3869b 100644 --- a/test/re-frame.coffee +++ b/test/re-frame.coffee @@ -33,10 +33,17 @@ butlast = (xs) -> xs[..-2] # sic! o.spec "mreframe/re-frame", -> - _reset = -> rf._init {redraw: identity} - - o.before _reset - o.afterEach _reset + {error, warn} = console + _reset = (loggers = {error, warn}) -> rf._init {redraw: identity}; Object.assign console, loggers + + o.before -> _reset error: o.spy(), warn: o.spy() + o.afterEach -> + _reset error: o.spy(), warn: o.spy() + rf.clearEvent() + rf.clearCofx() + rf.clearSub() + rf.clearFx() + o.after -> _reset {error, warn} o "getCoeffect()", -> context = mkContext() @@ -255,6 +262,10 @@ o.spec "mreframe/re-frame", -> now = new Date cofx = (cofx, key='time') -> assoc cofx, key, now o(rf.regCofx 'now', cofx).equals(undefined) "regCofx returns undefined" + o(rf.regCofx 'now', cofx).equals(undefined) "allows overwriting existing coeffects" + o(console.warn.callCount).equals(1) "a warning message is printed" + msg = "re-frame: overwriting coeffect handler for: 'now'" + o(console.warn.args).deepEquals([msg]) "the warning message describes the issue" context = mkContext() for [k, x] in [['time', rf.injectCofx 'now'], ['date', (rf.injectCofx 'now', 'date')]] do (k, x) -> @@ -272,14 +283,20 @@ o.spec "mreframe/re-frame", -> do (k) -> rf.regCofx k, (cofx) -> assoc cofx, k, now x = rf.injectCofx 'time' y = rf.injectCofx 'date' - o(rf.clearCofx 'time').equals(undefined) "returns undefined" - o(-> x.before mkContext()).throws(TypeError) "the coeffect cannot be used anymore" + o(rf.clearCofx 'time').equals(undefined) "returns undefined [1]" + o(x.before mkContext()).deepEquals( mkContext() ) "the coeffect doesn't affect the context anymore" + o(console.error.callCount).equals(1) "an error message is printed [1]" + msg = "re-frame: no coeffect handler registered for: 'time'" + o(console.error.args).deepEquals([msg]) "the error message describes the issue [1]" isInterceptor(rf.injectCofx 'time') "though existence of coeffects isn't checked upon injection" o(rf.clearCofx 'time').equals(undefined) "returns undefined on nonexistent" o(rf.getCoeffect (y.before mkContext()), 'date') .equals(now) "other coeffects aren't affected" - o( rf.clearCofx() ).equals(undefined) "returns undefined" - o(-> y.before mkContext()).throws(TypeError) "all coeffects were unregistered" + o( rf.clearCofx() ).equals(undefined) "returns undefined [2]" + o(y.before mkContext()).deepEquals( mkContext() ) "all coeffects were unregistered" + o(console.error.callCount).equals(2) "an error message is printed [2]" + msg = "re-frame: no coeffect handler registered for: 'date'" + o(console.error.args).deepEquals([msg]) "the error message describes the issue [2]" o "dispatchSync() + db builtin effect", -> {$assocIn, $resetDb} = $dbUpdates() @@ -334,6 +351,10 @@ o.spec "mreframe/re-frame", -> {$resetDb} = $dbUpdates() $resetDb() o(rf[reg] id1, fn1).equals(undefined) "returns undefined" + o(rf[reg] id1, fn1).equals(undefined) "allows overwriting existing events" + o(console.warn.callCount).equals(1) "a warning message is printed" + msg = "re-frame: overwriting event handler for: '#{id1}'" + o(console.warn.args).deepEquals([msg]) "the warning message describes the issue" rf.dispatchSync [id1, 'foo', bar: 'baz'] rf.dispatchSync [id1, 'answer', 42] db = answer: 42, foo: {bar: 'baz'} @@ -370,19 +391,30 @@ o.spec "mreframe/re-frame", -> o "clearEvent()", -> {$resetDb, $assocIn} = $dbUpdates() - o(rf.clearEvent 'assoc-in').equals(undefined) "returns undefined" + o(rf.clearEvent 'assoc-in').equals(undefined) "returns undefined [1]" o(-> $assocIn ['foo', 'bar'], 'baz') - .throws(TypeError) "removes passed event" + .notThrows(Error) "evoking removed event does not raise an error" + o(console.error.callCount).equals(1) "an error message is printed" + msg = "re-frame: no event handler registered for: 'assoc-in'" + o(console.error.args).deepEquals([msg]) "the error message describes the issue" o(rf.clearEvent 'assoc-in').equals(undefined) "returns undefined on nonexistent" $resetDb() - o(rf.clearEvent()).equals(undefined) "returns undefined" - o($resetDb).throws(TypeError) "removes all events" + o(console.error.callCount).equals(1) "no error message before event removal" + o(rf.clearEvent()).equals(undefined) "returns undefined [2]" + $resetDb() + o(console.error.callCount).equals(2) "an error message is printed [2]" + msg = "re-frame: no event handler registered for: 'reset-db'" + o(console.error.args).deepEquals([msg]) "the error message describes the issue [2]" o "regSub() + subscribe()", -> {$resetDb, $assocIn} = $dbUpdates() $resetDb(); $assocIn ['foo', 'bar'], 'baz' foo = o.spy getIn o(rf.regSub 'foo', foo).equals(undefined) "regSub returns undefined" + o(rf.regSub 'foo', foo).equals(undefined) "allows overwriting existing subscriptions" + o(console.warn.callCount).equals(1) "a warning message is printed" + msg = "re-frame: overwriting subscription handler for: 'foo'" + o(console.warn.args).deepEquals([msg]) "the warning message describes the issue" query = ['foo'] fooSub = rf.subscribe query query2 = ['foo', 'bar'] @@ -474,14 +506,20 @@ o.spec "mreframe/re-frame", -> rf.regSub 'oob', (db) -> db.foo x = rf.subscribe ['boo'] y = rf.subscribe ['oob'] - o(deref x).equals($db().foo) - o(rf.clearSub 'boo').equals(undefined) "returns undefined" + o(deref x).equals($db().foo) "calculation created successfully" + o(rf.clearSub 'boo').equals(undefined) "returns undefined [1]" o(deref x).equals($db().foo) "calculation still exists" - o(-> deref rf.subscribe ['boo']).throws(TypeError) "but subsription cannot access it anymore" + o(rf.subscribe ['boo']).equals(undefined) "but subsription cannot access it anymore" + o(console.error.callCount).equals(1) "an error message is printed" + msg = "re-frame: no subscription handler registered for: 'boo'" + o(console.error.args).deepEquals([msg]) "the error message describes the issue" o(rf.clearSub 'boo').equals(undefined) "returns undefined on nonexistent" - o( rf.clearSub() ).equals(undefined) "returns undefined" + o( rf.clearSub() ).equals(undefined) "returns undefined [2]" o(deref y).equals($db().foo) "uncached calculation also still exists" - o(-> deref rf.subscribe ['oob']).throws(TypeError) "but all subscriptions were removed now" + o(rf.subscribe ['oob']).equals(undefined) "but all subscriptions were removed now" + o(console.error.callCount).equals(2) "an error message is printed [2]" + msg = "re-frame: no subscription handler registered for: 'oob'" + o(console.error.args).deepEquals([msg]) "the error message describes the issue [2]" o "clearSubscriptionCache()", -> {$resetDb, $assocIn} = $dbUpdates() @@ -509,6 +547,10 @@ o.spec "mreframe/re-frame", -> o "regFx()", -> log = o.spy() o(rf.regFx 'log', log).equals(undefined) "returns undefined" + o(rf.regFx 'log', log).equals(undefined) "allows overwriting existing effects" + o(console.warn.callCount).equals(1) "a warning message is printed" + msg = "re-frame: overwriting effect handler for: 'log'" + o(console.warn.args).deepEquals([msg]) "the warning message describes the issue" rf.regEventFx 'log', [rf.unwrap], (_, msg) -> log: msg rf.dispatchSync ['log', "Foo"] o(log.callCount).equals(1) "registered effect actioned once on db count" @@ -525,14 +567,18 @@ o.spec "mreframe/re-frame", -> rf.regFx 'doOtherStuff', identity rf.regEventFx 'do-stuff', -> doStuff: yes rf.regEventFx 'do-other-stuff', -> doOtherStuff: yes - o(rf.clearFx 'doStuff').equals(undefined) "returns undefined" - o(-> rf.dispatchSync ['do-stuff']) - .throws(TypeError) "effect was removed" + o(rf.clearFx 'doStuff').equals(undefined) "returns undefined [2]" + o(-> rf.dispatchSync ['do-stuff']).notThrows(Error) "evoking removed effect does not raise an error" + o(console.error.callCount).equals(1) "an error message is printed [1]" + msg = "re-frame: no effect handler registered for: 'doStuff'" + o(console.error.args).deepEquals([msg]) "the error message describes the issue [1]" o(rf.clearFx 'doStuff').equals(undefined) "returns undefined on nonexistent" - o( rf.clearFx() ).equals(undefined) "returns undefined" - o(-> rf.dispatchSync ['do-other-stuff']) - .throws(TypeError) "all effects were removed" - o($resetDb).notThrows(Error) "except for builtin ones" + o( rf.clearFx() ).equals(undefined) "returns undefined [2]" + rf.dispatchSync ['do-other-stuff'] + o(console.error.callCount).equals(2) "an error message is printed [2]" + msg = "re-frame: no effect handler registered for: 'doOtherStuff'" + o(console.error.args).deepEquals([msg]) "the error message describes the issue [2]" + o($resetDb).notThrows(Error) "builtin effects are not affected" o "fx builtin effect", -> order = [] diff --git a/test/reagent.coffee b/test/reagent.coffee index 807f0aa..70ead4a 100644 --- a/test/reagent.coffee +++ b/test/reagent.coffee @@ -1,21 +1,24 @@ -[o, {type, identity, keys, isDict, assocIn, merge, multi}, _atom, r, {testAtom}] = ['ospec', '../src/util', '../src/atom', '../src/reagent', './atom'].map require +[o, {type, identity, keys, assocIn, merge, multi}, _atom, r, {testAtom}] = ['ospec', '../src/util', '../src/atom', '../src/reagent', './atom'].map require {deref, reset, resetVals, swap, swapVals, compareAndSet} = _atom -CLS = class: undefined, className: undefined, classList: undefined hstype = 'ø' fgtag = '⌷' -m = hyperscript = (tag, ...args) -> - [attrs, children] = if isDict args[0] then [args[0], args[1..]] else [{}, args] - {tag, attrs, children, hstype} +m = hyperscript = (tag, attrs, ...children) -> + [attrs, children] = [{}, [attrs, ...children]] if attrs?.hstype is hstype + {tag, attrs, children, hstype, _parent: null} hyperscript.fragment = (attrs, children) -> hyperscript fgtag, attrs, ...children -vnode = (tag, attrs={}, children=[]) -> {tag, children, hstype, attrs: merge CLS, attrs} +# the tuple grouping of children is a performance optimization +vnode$ = (tag, attrs={}, children, _parent=null) -> {hstype, tag, attrs, _parent, children} +vnode_ = (tag, attrs={}, children, _parent=null) -> vnode$ tag, attrs, [undefined, children], _parent +vnode = (tag, attrs={}, children=[], _parent=null) -> vnode$ tag, attrs, [children[0], children[1..]], _parent + o.spec "mreframe/reagent", -> - _reset = -> r._init {hyperscript, redraw: identity, mount: identity}; r.resetCache() + _reset = -> r._init {hyperscript, redraw: (->), mount: (->)}; r.resetCache() o.before _reset o.afterEach _reset @@ -67,26 +70,28 @@ o.spec "mreframe/reagent", -> o(deref cur).equals(x) "new value is passed as-is (function)" o(redraw.callCount).equals(2) o(reset cur, [12]).deepEquals([12]) - o(deref cur).equals(x) "setting an equal value doesn't replace the old one (function)" - o(redraw.callCount).equals(2) "setting an equal value doesn't trigger a redraw (function)" + o(deref cur).notEquals(x) "setting an equal value replaces the old one (function)" + o(redraw.callCount).equals(3) "setting an equal value triggers a redraw (function)" data.answer = 42 o(deref cur).equals(42) "updating the source externally changes deref result (function)" - atom = _atom.atom foo: {bar: 42, baz: 15} + atom = r.atom foo: {bar: 42, baz: 15} cur2 = r.cursor atom, ['foo', 'bar'] - o(deref cur2).equals(42) "works on atoms (deref)" - o(redraw.callCount).equals(2) "deref doesn't trigger redraw (atom)" - o(swap cur2, (n) -> n+1).equals(43) "works on atoms (swap)" - o(redraw.callCount).equals(3) "swap triggers redraw (atom)" - o(deref atom).deepEquals(foo: {bar: 43, baz: 15}) "data is updated in-path in the parent atom" - o(deref cur2).equals(43) "swap changes deref result (atom)" + o(deref cur2).equals(42) "works on r.atoms (deref)" + o(redraw.callCount).equals(3) "deref triggers redraw (r.atom)" + deref cur2 + o(redraw.callCount).equals(3) "deref twice-in-a-row doesn't trigger a redraw (r.atom)" + o(swap cur2, (n) -> n+1).equals(43) "works on r.atoms (swap)" + o(redraw.callCount).equals(5) "swap triggers redraw (r.atom)" + o(deref atom).deepEquals(foo: {bar: 43, baz: 15}) "data is updated in-path in the parent r.atom" + o(deref cur2).equals(43) "swap changes deref result (r.atom)" o(reset cur2, x).deepEquals([12]) o(deref cur2).equals(x) "new value is passed as-is (function)" - o(redraw.callCount).equals(4) + o(redraw.callCount).equals(7) o(reset cur2, [12]).deepEquals([12]) - o(deref cur2).equals(x) "setting an equal value doesn't replace the old one (atom)" - o(redraw.callCount).equals(4) "setting an equal value doesn't trigger a redraw (atom)" + o(deref cur2).notEquals(x) "setting an equal value replaces the old one (r.atom)" + o(redraw.callCount).equals(9) "setting an equal value triggers a redraw (r.atom)" swap atom, assocIn, ['foo', 'bar'], 42 - o(deref cur2).equals(42) "updating the source externally changes deref result (atom)" + o(deref cur2).equals(42) "updating the source externally changes deref result (r.atom)" o "classNames()", -> cls1 = "foo bar" @@ -110,22 +115,28 @@ o.spec "mreframe/reagent", -> o(r.createElement 'img', obj) .deepEquals(vnode 'img', obj) "renders tag vnodes with props" o(r.createElement 'div', obj, str, 42) - .deepEquals(vnode 'div', obj, [str, 42]) "renders tag vnodes with props and children" - o(r.createElement 'div', str, 42) - .deepEquals(vnode 'div', {}, [str, 42]) "renders tag vnodes with children" + .deepEquals(vnode_ 'div', obj, [str, 42]) "renders tag vnodes with props and children" o(r.createElement 'div', clsStr, str, 42) - .deepEquals(vnode 'div', clsStr, [str, 42]) "accepts CSS class as a string" + .deepEquals(vnode_ 'div', clsStr, [str, 42]) "accepts CSS class as a string" o(r.createElement 'div', clsList, str, 42) - .deepEquals(vnode 'div', clsStr, [str, 42]) "accepts CSS class as a list" + .deepEquals(vnode_ 'div', clsStr, [str, 42]) "accepts CSS class as a list" o(r.createElement 'div', clsDict, str, 42) - .deepEquals(vnode 'div', clsStr, [str, 42]) "accepts CSS class as a dict" + .deepEquals(vnode_ 'div', clsStr, [str, 42]) "accepts CSS class as a dict" o(r.createElement mcomp).deepEquals(vnode mcomp) "renders mcomponent vnodes" o(r.createElement mcomp, obj) .deepEquals(vnode mcomp, obj) "renders mcomponent vnodes with props" o(r.createElement mcomp, obj, str, 42) - .deepEquals(vnode mcomp, obj, [str, 42]) "renders mcomponent vnodes with props and children" - o(r.createElement mcomp, str, 42) - .deepEquals(vnode mcomp, {}, [str, 42]) "renders mcomponent vnodes with children" + .deepEquals(vnode_ mcomp, obj, [str, 42]) "renders mcomponent vnodes with props and children" + onclick = o.spy (e) -> [@, e, 42] + {attrs} = r.createElement 'button', {onclick, onfocus: null} + o(attrs.onclick).notEquals(onclick) "events (attrs of tags with keys starting with 'on') are modified" + o(type attrs.onclick).equals(Function) "events are wrapped into a function" + [evt, self] = [{name: 'event'}, {name: 'this'}] + res = attrs.onclick.call self, evt + o(evt.redraw).equals(false) "event wrappers mark the passed event to disable redraws" + o(onclick.callCount).equals(1) "event wrappers invoke the wrapped event handlers" + o(res).deepEquals([self, evt, 42]) "event wrappers pass 'this' and the event, and return result of the handler" + o(attrs.onfocus).equals(null) "non-function event values aren't modified" o "adaptComponent()", -> mcomp = view: -> "Hello, World!" @@ -155,93 +166,106 @@ o.spec "mreframe/reagent", -> o(r.asElement ['div', str, 42]) .deepEquals(vnode 'div', {}, [str, 42]) "renders HTML vnodes" o(r.asElement ['div', obj, str, 42]) - .deepEquals(vnode 'div', obj, [str, 42]) "renders HTML vnodes with props" + .deepEquals(vnode_ 'div', obj, [str, 42]) "renders HTML vnodes with props" o(r.asElement r.with meta, ['div', str, 42]) .deepEquals(vnode 'div', meta, [str, 42]) "renders HTML vnodes with meta" o(r.asElement r.with meta, ['div', obj, str, 42]) - .deepEquals(vnode 'div', metaObj, [str, 42]) "renders HTML vnodes with props and meta" + .deepEquals(vnode_ 'div', metaObj, [str, 42]) "renders HTML vnodes with props and meta" o(r.asElement r.with meta, ['div', obj2, str, 42]) - .deepEquals(vnode 'div', metaObj, [str, 42]) "renders HTML vnodes with props and meta overriding props" + .deepEquals(vnode_ 'div', metaObj, [str, 42]) "renders HTML vnodes with props and meta overriding props" rcomp = mcomp = view: fn o(r.asElement ['>', mcomp, str, 42]) .deepEquals(vnode mcomp, {}, [str, 42]) "renders :> vnodes" o(r.asElement ['>', mcomp, obj, str, 42]) - .deepEquals(vnode mcomp, obj, [str, 42]) "renders :> vnodes with props" + .deepEquals(vnode_ mcomp, obj, [str, 42]) "renders :> vnodes with props" o(r.asElement r.with meta, ['>', mcomp, str, 42]) .deepEquals(vnode mcomp, meta, [str, 42]) "renders :> vnodes with meta" o(r.asElement r.with meta, ['>', mcomp, obj, str, 42]) - .deepEquals(vnode mcomp, metaObj, [str, 42]) "renders :> vnodes with props and meta" + .deepEquals(vnode_ mcomp, metaObj, [str, 42]) "renders :> vnodes with props and meta" o(r.asElement r.with meta, ['>', mcomp, obj2, str, 42]) - .deepEquals(vnode mcomp, metaObj, [str, 42]) "renders :> vnodes with props and meta overriding props" - children = [(vnode 'div'), (vnode 'span', obj, [42])] + .deepEquals(vnode_ mcomp, metaObj, [str, 42]) "renders :> vnodes with props and meta overriding props" + children = [(vnode 'div'), (vnode_ 'span', obj, [42])] o(r.asElement ['<>', ['div'], ['span', obj, 42]]) .deepEquals(m.fragment {}, children) "renders :<> vnodes" o(r.asElement (r.with meta, ['<>', ['div'], ['span', obj, 42]])) .deepEquals(m.fragment meta, children) "renders :<> vnodes with meta" form = [rcomp, obj, str, 42] o(r.asElement form) - .deepEquals(vnode rcomp, argv: form) "renders class rcomponent vnodes" + .deepEquals(vnode_ rcomp, argv: form) "renders class rcomponent vnodes" form = r.with meta, [rcomp, obj, str, 42] props = merge meta, argv: form o(r.asElement form) - .deepEquals(vnode rcomp, props) "renders class rcomponent vnodes with meta" + .deepEquals(vnode_ rcomp, props) "renders class rcomponent vnodes with meta" fcomp = (foo, bar) -> ['div', {}, foo, bar] frcomp = (r.asElement [fcomp]).tag state = {} + frcompKeys = ['oninit', 'onbeforeupdate', 'view'] o(type frcomp).equals(Object) "renders function rcomponent vnodes with an object tag" - o(keys frcomp).deepEquals(['oninit', 'view']) "frcomponent is a Mithril component" - o(frcomp.oninit {state}).equals(undefined) "frcomponent .oninit returns undefined" - o(keys state).deepEquals(['_comp', '_atom']) "frcomponent .oninit sets ._comp and ._atom on state" + o(keys frcomp).deepEquals(frcompKeys) "frcomponent is a Mithril component" + o(frcomp.oninit.call state, {state}) + .equals(undefined) "frcomponent .oninit returns undefined" + stateKeys = ['_comp', '_subs', '_atom', '_view'] + o(keys state).deepEquals(stateKeys) "frcomponent .oninit sets ._comp, ._subs, ._atom and ._view on state" o(state._comp).equals(frcomp) "frcomponent state._comp is the function component" o(type state._atom).equals(type r.atom()) "frcomponent state._atom is a RAtom" o(deref state._atom).equals(undefined) "frcomponent state._atom is initially set to undefined" - o(frcomp.view {state, attrs: {argv: [fcomp, obj, str, 42]}}) - .deepEquals(vnode 'div', {}, [obj, str]) "frcomponent .view renders its Hiccup forms" - o(keys state).deepEquals(['_comp', '_atom']) "frcomponent .view doesn't make hidden state updates" + vn = {state, attrs: {argv: [fcomp, obj, str, 42]}} + o(frcomp.view.call state, vn) + .deepEquals(vnode_ 'div', {}, [obj, str], vn) "frcomponent .view renders its Hiccup forms" + o(keys state).deepEquals([...stateKeys, '_argv']) "frcomponent .view sets ._argv on state" form = [fcomp, obj, str, 42] o(r.asElement form) - .deepEquals(vnode frcomp, argv: form) "repeated render of function rcomponent reuses cached value" + .deepEquals(vnode_ frcomp, argv: form) "repeated render of function rcomponent reuses cached value" form = r.with meta, [fcomp, obj, str, 42] props = merge meta, argv: form o(r.asElement form) - .deepEquals(vnode frcomp, props) "meta can be supplied to frcomponent via r.with" + .deepEquals(vnode_ frcomp, props) "meta can be supplied to frcomponent via r.with" compInit = o.spy() fcomp2 = (...args) -> compInit ...args; fcomp frcomp2 = (r.asElement [fcomp2]).tag state = {} - o(keys frcomp2).deepEquals(['oninit', 'view']) "inited frcomponent is a Mithril component" - o(frcomp2.oninit {state}).equals(undefined) "inited frcomponent .oninit returns undefined" - o(keys state).deepEquals(['_comp', '_atom']) "inited frcomponent .oninit sets ._comp and ._atom on state" + o(keys frcomp2).deepEquals(frcompKeys) "inited frcomponent is a Mithril component" + o(frcomp2.oninit.call state, {state}) + .equals(undefined) "inited frcomponent .oninit returns undefined" + o(keys state).deepEquals(stateKeys) "inited frcomponent .oninit sets ._comp, ._subs, ._atom and ._view on state" o(state._comp).equals(frcomp2) "inited frcomponent state._comp is the function component" o(type state._atom).equals(type r.atom()) "inited frcomponent state._atom is a RAtom" o(deref state._atom).equals(undefined) "inited frcomponent state._atom is initially set to undefined" o(compInit.callCount).equals(0) "inited frcomponent .oninit doesn't trigger initial code" vn = {state, attrs: {argv: [fcomp2, obj, str, 42]}} - o(frcomp2.view vn) - .deepEquals(vnode 'div', {}, [obj, str]) "inited frcomponent .view renders its Hiccup forms on 1st run" + o(frcomp2.view.call state, vn) + .deepEquals(vnode_ 'div', {}, [obj, str], vn) "inited frcomponent .view renders its Hiccup forms on 1st run" o(compInit.callCount).equals(1) "inited frcomponent .view triggers initial code on 1st run" o(compInit.args).deepEquals([obj, str, 42]) "inited frcomponent passes correct arguments to its initial code" o(state._view).equals(fcomp) "inited frcomponent .view sets state._view on 1st run" - o(frcomp2.view vn) - .deepEquals(vnode 'div', {}, [obj, str]) "inited frcomponent .view renders its Hiccup forms on 2nd run" + o(frcomp2.view.call state, vn) + .deepEquals(vnode_ 'div', {}, [obj, str], vn) "inited frcomponent .view renders its Hiccup forms on 2nd run" o(compInit.callCount).equals(1) "inited frcomponent .view doesn't trigger initial code on 2nd run" form = [fcomp2, obj, str, 42] o(r.asElement form) - .deepEquals(vnode frcomp2, argv: form) "repeated render of inited function rcomponent reuses cached value" + .deepEquals(vnode_ frcomp2, argv: form) "repeated render of inited function rcomponent reuses cached value" + # ensuring that performance optimization doesn't mess up keys + spans = (keys) -> [1, 2, 3].map (key) -> unless keys then ['span', key] else r.with {key}, ['span', key] + spanVnodes = (keys) -> (spans keys).map r.asElement + o(r.asElement ['div', ...spans()]) + .deepEquals(vnode 'div', {}, spanVnodes()) "renders children without keys without grouping them" + o(r.asElement ['div', ...spans 'keys']) + .deepEquals(vnode$ 'div', {}, spanVnodes 'keys') "renders children with keys as a single list" o "resetCache()", -> fcomp = -> 42 frcomp = (r.asElement [fcomp]).tag o(r.asElement [fcomp]) - .deepEquals(vnode frcomp, argv: [fcomp]) "cached frcomp is reused" + .deepEquals(vnode_ frcomp, argv: [fcomp]) "cached frcomp is reused" o( r.resetCache() ).equals(undefined) "_resetCache returns undefined" frcomp2 = (r.asElement [fcomp]).tag o(frcomp2).notEquals(frcomp) "new component instance is rendered after cache reset" o(r.asElement [fcomp]) - .deepEquals(vnode frcomp2, argv: [fcomp]) "new cached frcomp is reused" + .deepEquals(vnode_ frcomp2, argv: [fcomp]) "new cached frcomp is reused" o "createClass()", -> obj = answer: 42 + str = "Hello World" order = [] push = (name, value) ->-> order.push name; value rendered = vnode 'span', obj, ["Yay"] @@ -254,32 +278,33 @@ o.spec "mreframe/reagent", -> shouldComponentUpdate: o.spy push 'shouldComponentUpdate', yes beforeComponentUnmounts: o.spy push 'beforeComponentUnmounts', no render: o.spy push 'render', rendered - reagentRender: o.spy push 'reagentRender', ['span', "Hello World"] + reagentRender: o.spy push 'reagentRender', ['span', str] rcomp = r.createClass rcompDef - rcompDef2 = reagentRender: o.spy push 'reagentRender2', ['span', "Hello World"] + rcompDef2 = reagentRender: o.spy push 'reagentRender2', ['span', str] rcomp2 = r.createClass rcompDef2 mcompMethods = ['oninit', 'oncreate', 'onupdate', 'onremove', 'onbeforeupdate', 'onbeforeremove', 'view'] state = {} self = {state, attrs: {argv: [rcomp, 'foo', obj]}} o(keys rcomp).deepEquals(mcompMethods) "produces a Mithril component" - o(rcomp.oninit self).equals(undefined) "method .oninit works" + o(rcomp.oninit.call state, self).equals(undefined) "method .oninit works" o(order) .deepEquals(['getInitialState', 'constructor']) "method .oninit invokes getInitialState and constructor" - o(keys state).deepEquals(['_comp', '_atom']) "method .oninit sets _comp and _atom in state" - o(state._comp).equals(rcomp) "method .oninit sets _comp to current component" - o(deref state._atom).equals(obj) "method .oninit sets _atom to state from getInitialState" + o(keys state) + .deepEquals(['_comp', '_subs', '_atom']) "method .oninit sets ._comp, ._subs and ._atom in state" + o(state._comp).equals(rcomp) "method .oninit sets ._comp to current component" + o(deref state._atom).equals(obj) "method .oninit sets ._atom to state from getInitialState" for [fn, k, res] in [['oncreate', 'componentDidMount'], ['onbeforeupdate', 'shouldComponentUpdate', yes], ['onupdate', 'componentDidUpdate'], ['view', 'render', rendered], ['onremove', 'componentWillUnmount'], ['onbeforeremove', 'beforeComponentUnmounts', no]] do (fn, k, res) -> order = [] - o(rcomp[fn] self).equals(res) "method .#{fn} works" + o(rcomp[fn].call state, self).equals(res) "method .#{fn} works" o(order).deepEquals([k]) "method .#{fn} invokes #{k}" o(rcompDef[k].args.length).equals(1) "#{k} is passed 1 argument" o(rcompDef[k].args[0]).equals(self) "#{k} is passed vnode as an argument" - rcomp2.oninit self + rcomp2.oninit.call state, self order = [] - o(rcomp2.view self) - .deepEquals(vnode 'span', {}, ["Hello World"]) "method .view falls back to reagentRender" + o(rcomp2.view.call state, self) + .deepEquals(vnode 'span', {}, [str], self) "method .view falls back to reagentRender" o(order).deepEquals(['reagentRender2']) "method .view invokes reagentRender" o(rcompDef2.reagentRender.args) .deepEquals( (r.argv self)[1..] ) "reagentRender is passed vnode argv starting from 2nd as arguments" @@ -299,7 +324,7 @@ o.spec "mreframe/reagent", -> o(keys mcomp).deepEquals(['view']) "second argument is a Mithril component" o(type mcomp.view).equals(Function) o(mcomp.view {}) - .deepEquals(vnode frcomp, argv: form) "its .view renders the form" + .deepEquals(vnode_ frcomp, argv: form) "its .view renders the form" o "currentComponent()", -> log = o.spy() @@ -307,12 +332,13 @@ o.spec "mreframe/reagent", -> frcomp = (r.asElement [fcomp]).tag state = {} self = {state, attrs: {argv: [fcomp]}} - o(frcomp.view self).equals(42) + frcomp.oninit.call state, self + o(frcomp.view.call state, self).equals(42) o(log.callCount).equals(1) o(log.args[0]).equals(self) "provides access to vnode in function rcomponents" rcomp = r.createClass reagentRender: fcomp self = {state, attrs: {argv: [rcomp]}} - o(rcomp.view self).equals(42) + o(rcomp.view.call state, self).equals(42) o(log.callCount).equals(2) o(log.args[0]).equals(self) "provides access to vnode in class rcomponents" @@ -329,14 +355,16 @@ o.spec "mreframe/reagent", -> frcomp = (r.asElement [fcomp]).tag attrs = answer: 42, argv: [fcomp, obj, str, 42] state = {} - frcomp.oninit {state} - o(frcomp.view {state, attrs}) - .deepEquals(vnode 'div', attrs, [obj, str]) "props are supplied to function rcomponent" + frcomp.oninit.call state, {state} + vn = {state, attrs} + o(frcomp.view.call state, vn) + .deepEquals(vnode_ 'div', attrs, [obj, str], vn) "props are supplied to function rcomponent" rcomp = r.createClass reagentRender: fcomp state = {} - rcomp.oninit {state} - o(rcomp.view {state, attrs}) - .deepEquals(vnode 'div', attrs, [obj, str]) "props are supplied to class rcomponent" + rcomp.oninit.call state, {state} + vn = {state, attrs} + o(rcomp.view.call state, vn) + .deepEquals(vnode_ 'div', attrs, [obj, str], vn) "props are supplied to class rcomponent" o "argv()", -> rcomp = {} diff --git a/test/redraw-detection.coffee b/test/redraw-detection.coffee new file mode 100644 index 0000000..985bd8d --- /dev/null +++ b/test/redraw-detection.coffee @@ -0,0 +1,262 @@ +[o, {deref, reset}, r, rf] = ['ospec', '../src/atom', '../src/reagent', '../src/re-frame'].map require + +hstype = 'ø' + +hyperscript = (tag, attrs, ...children) -> + [attrs, children] = [{}, [attrs, ...children]] if attrs?.hstype is hstype + {tag, attrs, children, hstype, _parent: null} + +delay = (timeout) -> new Promise (resolve) -> setTimeout(resolve, timeout) + + +redraw = o.spy() + +constant = -> + constant._render() + ['span', "NOOP"] + +ext = -> + ext._render() + ['span', rf.dsub(['value'])] # this also tests RCursor (as it's returned by rf.subscribe) + +foo = (n) -> + foo._render() + ['span', n] + +bar = -> + counter = r.atom(1) + -> + bar._render() + _counter = deref counter + # WARNING: using swap() here would schedule counter increment on EVERY render of [bar] + setTimeout(-> reset counter, _counter + 1) if _counter < 3 + ['div', + [constant] + [foo, _counter] + [ext]] + +baz = -> baz._render(); [bar] + + +_init = (comp, vnode, parent=null) -> + comp._vnode = vnode + comp._state = vnode.state = {} + vnode.tag.oninit.call(comp._state, vnode) + _subs = comp._state._subs + comp._clearSubs = _subs.clear = o.spy _subs.clear.bind _subs + o(vnode._parent).equals(parent) "verifying the parent of #{comp.name}" + +_view = (comp) -> + comp._vnode.state = comp._state + comp._vnode.tag.view.call(comp._state, comp._vnode) + +_redrawCheck = (comp, value, comment, vnode = comp._vnode) -> + comp._vnode = vnode + vnode.state = comp._state + shouldRedraw = vnode.tag.onbeforeupdate.call(comp._state, vnode) + o(shouldRedraw).equals(value) ".onbeforeupdate should return #{value} #{comment} for #{comp.name}" + shouldRedraw + + +_assertSubs = (it, comment) -> + it = _assertSubs.last = {..._assertSubs.last, ...it} + count = _assertSubs.count += 1 + _comment = if comment then "#{comment} (##{count})" else "check ##{count}" + o(baz._state?._subs?.size).equals(it.baz) "#{_comment}: baz component should have #{it.baz} subs" + o(baz._clearSubs?.callCount).equals(it.bazClear) "#{_comment}: baz component should have cleared subs #{it.bazClear} times" + o(bar._state?._subs?.size).equals(it.bar) "#{_comment}: bar component should have #{it.bar} subs" + o(bar._clearSubs?.callCount).equals(it.barClear) "#{_comment}: bar component should have cleared subs #{it.barClear} times" + o(constant._state?._subs?.size).equals(it.constant) "#{_comment}: constant component should have #{it.constant} subs" + o(constant._clearSubs?.callCount) + .equals(it.constClear) "#{_comment}: constant component should have cleared subs #{it.constClear} times" + o(foo._state?._subs?.size).equals(it.foo) "#{_comment}: foo component should have #{it.foo} subs" + o(foo._clearSubs?.callCount).equals(it.fooClear) "#{_comment}: foo component should have cleared subs #{it.fooClear} times" + o(ext._state?._subs?.size).equals(it.ext) "#{_comment}: ext component should have #{it.ext} subs" + o(ext._clearSubs?.callCount).equals(it.extClear) "#{_comment}: ext component should have cleared subs #{it.extClear} times" + +_assertRedraws = (it, comment="") -> + it = _assertRedraws.last = {..._assertRedraws.last, ...it} + count = _assertRedraws.count += 1 + _comment = if comment then "#{comment} (##{count})" else "check ##{count}" + o(baz._render.callCount).equals(it.baz) "#{_comment}: baz component should've been redrawn #{it.baz} times" + o(bar._render.callCount).equals(it.bar) "#{_comment}: bar component should've been redrawn #{it.bar} times" + o(constant._render.callCount).equals(it.constant) "#{_comment}: constant component should've been redrawn #{it.constant} times" + o(foo._render.callCount).equals(it.foo) "#{_comment}: foo component should've been redrawn #{it.foo} times" + o(ext._render.callCount).equals(it.ext) "#{_comment}: ext component should've been redrawn #{it.ext} times" + o(redraw.callCount).equals(it.redraw) "#{_comment}: redraw should've been called #{it.redraw} times" + + +o.spec "redraw detection", -> + + o.before -> + rf._init {hyperscript, redraw, mount: (->)} + rf.regEventDb 'init', -> value: 1 + rf.regEventDb 'inc-value', (db) -> {...db, value: db.value + 1} + rf.regEventDb 'set', [rf.trimV], (db, [key, value]) -> {...db, [key]: value} + rf.regSub 'value', (db) -> db.value + constant._render = o.spy() + ext._render = o.spy() + foo._render = o.spy() + bar._render = o.spy() + baz._render = o.spy() + _assertSubs.last = baz: undefined, bazClear: undefined, bar: undefined, barClear: undefined,\ + foo: undefined, fooClear: undefined, ext: undefined, extClear: undefined,\ + constant: undefined, constClear: undefined + _assertSubs.count = 0 + _assertRedraws.last = baz: 0, bar: 0, foo: 0, ext: 0, constant: 0, redraw: 0 + _assertRedraws.count = 0 + + + o "initial render", -> + rf.dispatchSync ['init'] + _assertRedraws {redraw: 1}, "dispatch-sync [:init]" + _init baz, r.asElement([baz]) + _assertSubs {baz: 0, bazClear: 0} + _redrawCheck baz, yes, "before 1st render" + _assertRedraws {}, "init [baz] / redraw-check [baz]" + _init bar, (_view baz), baz._vnode + _assertRedraws {baz: 1}, "render [baz] / init [bar]" + _assertSubs {bazClear: 1, bar: 0, barClear: 0}, "init [bar]" + _redrawCheck bar, yes, "before 1st render" + {children: [constVnode, [fooVnode, extVnode]]} = _view bar # 'div' vnode + _assertRedraws {bar: 1}, "render [baz]" + _assertSubs {barClear: 1, bar: 1, baz: 1}, "render [baz]" + _init constant, constVnode, bar._vnode + _init foo, fooVnode, bar._vnode + _init ext, extVnode, bar._vnode + _assertRedraws {}, "init [constant], [foo 1], [ext]" + _assertSubs {foo: 0, fooClear: 0, ext: 0, extClear: 0, constant: 0, constClear: 0}, "init [constant] / init [foo] / init [ext]" + + _redrawCheck constant, yes, "before 1st render" + _view constant + _assertRedraws {constant: 1}, "render [constant]" + o(r.argv fooVnode).deepEquals([foo, 1]) "initial form of [foo] is [foo 1]" + _redrawCheck foo, yes, "before 1st render" + _view foo + _assertRedraws {foo: 1}, "render [foo 1]" + _assertSubs {fooClear: 1, constClear: 1}, "render [foo 1]" + _redrawCheck ext, yes, "before 1st render" + {tag, children} = _view ext + o([tag, ...children]).deepEquals(['span', 1, []]) "initial view of ext is ['span' 1]" + _assertRedraws {ext: 1}, "render [ext]" + _assertSubs {extClear: 1, ext: 1, bar: 2, baz: 2}, "render [ext]" + + o "initial redraw checks", -> + redraw() + _assertRedraws {redraw: 2}, "redraw() call" + _redrawCheck comp, no, "before first change" for comp in [baz, bar, constant, foo, ext] + _assertRedraws {}, "redraw-check [baz], [bar], [constant], [foo 1], [ext]" + _assertSubs {}, "redraw-check [baz], [bar], [constant], [foo 1], [ext]" + + o "state change redraw", -> + rf.dispatchSync ['inc-value'] + _assertRedraws {redraw: 3}, "dispatch-sync [:inc-value]" + _redrawCheck baz, yes, "after 1st change" + _assertSubs {}, "redraw-check [baz]" + bar._vnode = _view baz + _assertRedraws {baz: 2}, "render [baz]" + _assertSubs {bazClear: 2, baz: 0}, "render [baz]" + _redrawCheck bar, yes, "after 1st change" + _assertSubs {}, "redraw-check [bar]" + {children: [constVnode, [fooVnode, extVnode]]} = _view bar # 'div' vnode + _assertRedraws {bar: 2}, "render [bar]" + _assertSubs {barClear: 2, bar: 1, baz: 1}, "render [bar]" + + _redrawCheck constant, no, "after 1st change", constVnode + _assertSubs {}, "redraw-check [constant]" + o(r.argv fooVnode).deepEquals([foo, 1]) "current form of foo is [foo 1]" + _redrawCheck foo, no, "after 1st change", fooVnode + _assertSubs {}, "redraw-check [foo 1]" + _redrawCheck ext, yes, "after 1st change", extVnode + _assertSubs {}, "redraw-check [ext]" + {tag, children} = _view ext + o([tag, ...children]).deepEquals(['span', 2, []]) "current view of ext is ['span' 2]" + _assertRedraws {ext: 2}, "render [ext]" + _assertSubs {extClear: 2, ext: 1, bar: 2, baz: 2}, "render [ext]" + + o "nil change redraw checks", -> + rf.dispatchSync ['set', 'answer', 42] + _assertRedraws {redraw: 4}, "dispatch-sync [:set :answer 42]" + _redrawCheck comp, no, "after a nil change" for comp in [baz, bar, constant, foo, ext] + _assertRedraws {}, "redraw-check [baz], [bar], [constant], [foo 1], [ext]" + _assertSubs {}, "redraw-check [baz], [bar], [constant], [foo 1], [ext]" + + o "redraw triggered by bar ratom change (twice but 2nd change would be nil)", -> + delay().then -> + _assertRedraws {redraw: 5}, "redraw triggered by scheduled updates in [bar]" + _redrawCheck baz, yes, "after 2nd change" + _assertSubs {}, "redraw-check [baz]" + bar._vnode = _view baz + _assertRedraws {baz: 3}, "render [baz]" + _assertSubs {bazClear: 3, baz: 0}, "render [baz]" + _redrawCheck bar, yes, "after 2nd change" + _assertSubs {}, "redraw-check [bar]" + {children: [constVnode, [fooVnode, extVnode]]} = _view bar # 'div' vnode + _assertRedraws {bar: 3}, "render [bar]" + _assertSubs {barClear: 3, bar: 1, baz: 1}, "render [bar]" + + _redrawCheck constant, no, "after 2nd change", constVnode + _assertSubs {}, "redraw-check [constant]" + o(r.argv fooVnode).deepEquals([foo, 2]) "current form of foo is [foo 2]" + _redrawCheck foo, yes, "after 2nd change", fooVnode + _assertSubs {}, "redraw-check [foo 2]" + _view foo + _assertRedraws {foo: 2}, "render [foo 2]" + _assertSubs {fooClear: 2}, "render [foo 2]" + _redrawCheck ext, no, "after 2nd change", extVnode + _assertSubs {bar: 2, baz: 2}, "redraw-check [ext]" # propagated [:value] subscription + + o "redraw triggered by another bar ratom change", -> + delay().then -> + _assertRedraws {redraw: 6}, "redraw triggered by scheduled update in [bar]" + _redrawCheck baz, yes, "after 3rd change" + _assertSubs {}, "redraw-check [baz]" + bar._vnode = _view baz + _assertRedraws {baz: 4}, "render [baz]" + _assertSubs {bazClear: 4, baz: 0}, "render [baz]" + _redrawCheck bar, yes, "after 3rd change" + _assertSubs {}, "redraw-check [bar]" + {children: [constVnode, [fooVnode, extVnode]]} = _view bar #
vnode + _assertRedraws {bar: 4}, "render [bar]" + _assertSubs {barClear: 4, bar: 1, baz: 1}, "render [bar]" + + _redrawCheck constant, no, "after 3rd change", constVnode + _assertSubs {}, "redraw-check [constant]" + o(r.argv fooVnode).deepEquals([foo, 3]) "current form of foo is [foo 3]" + _redrawCheck foo, yes, "after 3rd change", fooVnode + _assertSubs {}, "redraw-check [foo 3]" + _view foo + _assertRedraws {foo: 3}, "render [foo 3]" + _assertSubs {fooClear: 3}, "render [foo 3]" + _redrawCheck ext, no, "after 3rd change", extVnode + _assertSubs {bar: 2, baz: 2}, "redraw-check [ext]" # propagated [:value] subscription + + o "bar ratom reached 3 and no longer causes redraws (see [bar] definition)", -> + delay().then -> + _assertRedraws {}, "no redraw triggered in [bar] anymore" + + o "2nd state change redraw", -> + rf.dispatchSync ['set', 'value', 42] + _assertRedraws {redraw: 7}, "dispatch-sync [:value 42]" + _redrawCheck baz, yes, "after 4th change" + _assertSubs {}, "redraw-check [baz]" + bar._vnode = _view baz + _assertRedraws {baz: 5}, "render [baz]" + _assertSubs {bazClear: 5, baz: 0}, "render [baz]" + _redrawCheck bar, yes, "after 4th change" + _assertSubs {}, "redraw-check [bar]" + {children: [constVnode, [fooVnode, extVnode]]} = _view bar #
vnode + _assertRedraws {bar: 5}, "render [bar]" + _assertSubs {barClear: 5, bar: 1, baz: 1}, "render [bar]" + + _redrawCheck constant, no, "after 4th change", constVnode + _assertSubs {}, "redraw-check [constant]" + o(r.argv fooVnode).deepEquals([foo, 3]) "current form of foo is [foo 3]" + _redrawCheck foo, no, "after 4th change", fooVnode + _assertSubs {}, "redraw-check [foo 1]" + _redrawCheck ext, yes, "after 4th change", extVnode + _assertSubs {}, "redraw-check [ext]" + {tag, children} = _view ext + o([tag, ...children]).deepEquals(['span', 42, []]) "current view of ext is ['span' 42]" + _assertRedraws {ext: 3}, "render [ext]" + _assertSubs {extClear: 3, ext: 1, bar: 2, baz: 2}, "render [ext]" diff --git a/test/util.coffee b/test/util.coffee index 216c083..69f8c84 100644 --- a/test/util.coffee +++ b/test/util.coffee @@ -1,6 +1,6 @@ [o, util] = ['ospec', '../src/util'].map require {identity, type, keys, vals, entries, dict, merge, assoc, dissoc, update, getIn, - assocIn, updateIn, chunks, flatten, repr, eq, chain, multi} = util + assocIn, updateIn, chunks, flatten, repr, eq, eqShallow, identical, chain, multi} = util o.spec "mreframe/util", -> @@ -202,6 +202,21 @@ o.spec "mreframe/util", -> o(repr [9, 1, 3]).equals("[9,1,3]") "works on lists" o(repr [/x/]).equals('["/x/"]') "replaces RegExp with its pattern" + o "identical()", -> + o(identical "foo", "f"+"oo").equals(true) "returns true for identical values" + o(identical "foo", "bar").equals(false) "returns false for non-identical values" + it = [1, 2, 3] + o(identical it, it).equals(true) "lists are identical to themselves" + o(identical [1, 2, 3], [1, 2, 3]).equals(false) "but not to equivalent lists" + it = {foo: 1, bar: 2} + o(identical it, it).equals(true) "dicts are identical to themselves" + o(identical {foo: 1, bar: 2}, {foo: 1, bar: 2}) + .equals(false) "but not to equivalent dicts" + o(identical null, null).equals(true) "works on null" + o(identical undefined, undefined).equals(true) "works on undefined" + o(identical null, undefined).equals(false) "null is not identical to undefined" + o(identical NaN, NaN).equals(true) "works on NaN (all values are identical to themselves)" + o "eq()", -> o(eq 1, 1).equals(true) "tests for equality [1]" o(eq 1, 2).equals(false) "tests for equality [2]" @@ -235,6 +250,39 @@ o.spec "mreframe/util", -> o(eq null, undefined).equals(false) "null is not equal to undefined" o(eq NaN, NaN).equals(true) "works on NaN (all values are equal to themselves)" + o "eqShallow()", -> + o(eqShallow 1, 1).equals(true) "tests scalars for equality [1]" + o(eqShallow 1, 2).equals(false) "tests scalars for equality [2]" + o(eqShallow [1, 2, 3], [1, 2, 3]).equals(true) "works on flat lists [1]" + o(eqShallow [1, 2, 3], [1, 3, 2]).equals(false) "works on flat lists [2]" + o(eqShallow [1, 2, 3], "1,2,3").equals(false) "works on flat lists [3]" + o(eqShallow {foo: 1, bar: 2}, {bar: 2, foo: 1}) + .equals(true) "works on flat dicts [1]" + o(eqShallow {foo: 1, bar: 2}, {foo: 2, bar: 1}) + .equals(false) "works on flat dicts [2]" + o(eqShallow {foo: 1, bar: 2, baz: 3}, {foo: 1, bar: 2}) + .equals(false) "works on flat dicts [3]" + o(eqShallow {foo: 1, bar: 2}, {foo: 1, bar: 2, baz: 3}) + .equals(false) "works on flat dicts [4]" + baz = foo: 1, bar: 2 + abc = [1, 2, 3] + o(eqShallow [baz, abc], [baz, abc]).equals(true) "flat checks work on lists" + o(eqShallow {baz, abc}, {baz, abc}).equals(true) "flat checks work on dicts" + o(eqShallow [{foo: 1, bar: 2}], [{foo: 1, bar: 2}]) + .equals(false) "nested values are checked for being identical [1]" + o(eqShallow {baz: {foo: 1, bar: 2}}, {baz: {foo: 1, bar: 2}}) + .equals(false) "nested values are checked for being identical [2]" + o(eqShallow [[1, 2, 3]], [[1, 2, 3]]) + .equals(false) "nested values are checked for being identical [3]" + o(eqShallow {abc: [1, 2, 3]}, {abc: [1, 2, 3]}) + .equals(false) "nested values are checked for being identical [4]" + o(eqShallow null, null).equals(true) "works on null" + o(eqShallow undefined, undefined).equals(true) "works on undefined" + o(eqShallow null, undefined).equals(false) "null is not shallow-equal to undefined" + o(eqShallow NaN, NaN).equals(true) "works on NaN (all values are shallow-equal to themselves)" + o(eqShallow [NaN], [NaN]).equals(true) "works on NaN nested in lists" + o(eqShallow {NaN: NaN}, {NaN: NaN}).equals(true) "works on NaN nested in dicts" + o "chain()", -> a = foo: 1, bar: 2, baz: 3 o(chain a, [merge, bar: 42]) diff --git a/yarn.lock b/yarn.lock index aeda836..303273a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -122,6 +122,14 @@ base64-js@^1.0.2: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +benchmark@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/benchmark/-/benchmark-2.1.4.tgz#09f3de31c916425d498cc2ee565a0ebf3c2a5629" + integrity sha1-CfPeMckWQl1JjMLuVloOvzwqVik= + dependencies: + lodash "^4.17.4" + platform "^1.3.3" + bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9: version "4.11.9" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828" @@ -1156,6 +1164,11 @@ lodash.memoize@~3.0.3: resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-3.0.4.tgz#2dcbd2c287cbc0a55cc42328bd0c736150d53e3f" integrity sha1-LcvSwofLwKVcxCMovQxzYVDVPj8= +lodash@^4.17.4: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + magic-string@^0.23.2: version "0.23.2" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.23.2.tgz#204d7c3ea36c7d940209fcc54c39b9f243f13369" @@ -1407,6 +1420,11 @@ pbkdf2@^3.0.3: safe-buffer "^5.0.1" sha.js "^2.4.8" +platform@^1.3.3: + version "1.3.6" + resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.6.tgz#48b4ce983164b209c2d45a107adb31f473a6e7a7" + integrity sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg== + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"