├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── README.md ├── karma.conf.cjs ├── package-lock.json ├── package.json ├── src ├── .eslintrc.yml ├── babel-plugin-htm.js ├── babel-plugin-htm.md ├── h.d.ts ├── h.js ├── htm.js ├── hydrate.d.ts ├── hydrate.js ├── hydrate.md ├── index.d.ts ├── index.js ├── jsx.d.ts ├── map.d.ts ├── map.js ├── observable.d.ts ├── observable.js ├── observable.md ├── shared.d.ts ├── template.d.ts ├── template.js └── template.md ├── test ├── .eslintrc.yml ├── _polyfills.js ├── _utils.js ├── h │ ├── .eslintrc.yml │ ├── add-node.js │ ├── hyperscript.js │ ├── insert-bugs.js │ ├── insert-markers.js │ ├── insert.js │ ├── svg.js │ └── utils.js ├── htm │ ├── babel.js │ └── index.js ├── hydrate │ ├── .eslintrc.yml │ ├── hydrate.js │ ├── selector.js │ └── svg.js ├── map │ ├── .eslintrc.yml │ ├── dispose.js │ ├── map-basic.js │ ├── map-fragments.js │ ├── map-objects.js │ └── map.js ├── observable │ ├── .eslintrc.yml │ ├── S.js │ ├── child.js │ ├── dispose.js │ ├── observable.js │ ├── on.js │ ├── perf │ │ ├── index.js │ │ ├── lib │ │ │ ├── cellx.umd.js │ │ │ ├── ceres.css │ │ │ ├── jin-atom.js │ │ │ ├── jquery.js │ │ │ ├── kefir.js │ │ │ ├── knockout-3.4.0.js │ │ │ ├── mobx.umd.js │ │ │ ├── mol_atom.web.js │ │ │ ├── prop1.js │ │ │ ├── reactive.js │ │ │ └── reactor.js │ │ ├── perf.html │ │ └── perf.js │ ├── root.js │ ├── sample.js │ ├── transaction.js │ └── value.js ├── sinuous.js ├── template.js └── test.js └── tsconfig.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | 7 | lint: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | cache: npm 15 | - run: npm ci 16 | - run: npm run lint 17 | 18 | test: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - uses: actions/setup-node@v3 24 | with: 25 | cache: npm 26 | - run: npm ci 27 | - run: COVERAGE=true npm test -- --single-run --console --stack --log-level debug 28 | - name: Upload coverage to Codecov 29 | uses: codecov/codecov-action@v2.1.0 30 | with: 31 | verbose: true 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | *.sublime-project 4 | *.code-workspace 5 | yarn-error.log 6 | .nyc_output/ 7 | coverage 8 | *.lcov 9 | 10 | /site/public/ 11 | dist 12 | module 13 | !/bench/css/bootstrap/dist/ 14 | 15 | /bench/results/results.json 16 | /bench/yarn.lock 17 | /bench/trace.json 18 | 19 | /src/observable/test/perf/perf.txt 20 | .idea 21 | 22 | # Local Netlify folder 23 | .netlify 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sinuous 2 | 3 | [![Version](https://img.shields.io/npm/v/sinuous.svg?color=success&style=flat-square)](https://www.npmjs.com/package/sinuous) 4 | ![Badge size](https://img.badgesize.io/https://cdn.jsdelivr.net/npm/sinuous/+esm?compression=gzip&label=gzip&style=flat-square) 5 | [![codecov](https://img.shields.io/codecov/c/github/luwes/sinuous.svg?style=flat-square&color=success)](https://codecov.io/gh/luwes/sinuous) 6 | 7 | **npm**: `npm i sinuous` 8 | **cdn**: https://cdn.jsdelivr.net/npm/sinuous/+esm 9 | 10 | --- 11 | 12 | - **Small.** hello world at `~1.4kB` gzip. 13 | - **Fast.** [top ranked](https://rawgit.com/krausest/js-framework-benchmark/master/webdriver-ts-results/table.html) of 80+ UI libs. 14 | - **Truly reactive.** automatically derived from the app state. 15 | - **DevEx.** no compile step needed, choose your [view syntax](#view-syntax). 16 | 17 | --- 18 | 19 | ### Add-ons 20 | 21 | | Size | Name | Description | 22 | | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | --------------------------------------- | 23 | | ![Badge size](https://img.badgesize.io/https://cdn.jsdelivr.net/npm/sinuous/src/observable.js?compression=gzip&label=gzip&style=flat-square) | [`sinuous/observable`](./src/observable.md) | Tiny observable _(included by default)_ | 24 | | ![Badge size](https://img.badgesize.io/https://cdn.jsdelivr.net/npm/sinuous/src/map.js?compression=gzip&label=gzip&style=flat-square) | [`sinuous/map`](./src/map.js) | Fast list renderer | 25 | | ![Badge size](https://img.badgesize.io/https://cdn.jsdelivr.net/npm/sinuous/src/hydrate.js?compression=gzip&label=gzip&style=flat-square) | [`sinuous/hydrate`](./src/hydrate.md) | Hydrate static HTML | 26 | | ![Badge size](https://img.badgesize.io/https://cdn.jsdelivr.net/npm/sinuous/src/template.js?compression=gzip&label=gzip&style=flat-square) | [`sinuous/template`](./src/template.md) | Pre-rendered Template | 27 | 28 | ### Community 29 | 30 | - [**sinuous-context**](https://github.com/theSherwood/sinuous-context) ([@theSherwood](https://github.com/theSherwood)): A light-weight, fast, and easy to use context api for Sinuous. 31 | - [**memo**](https://github.com/luwes/memo) ([@luwes](https://github.com/luwes)): Memoize components and functions. 32 | - [**disco**](https://github.com/luwes/disco) ([@luwes](https://github.com/luwes)): Universal `connected` and `disconnected` lifecycle events. 33 | - [**sinuous-style**](https://github.com/theSherwood/sinuous-style) ([@theSherwood](https://github.com/theSherwood)): Scoped styles for Sinuous à la styled-jsx. 34 | - [**sinuous-lifecycle**](https://www.npmjs.com/package/sinuous-lifecycle) ([@heyheyhello](https://github.com/heyheyhello)): onAttach/onDetach DOM lifecycles. 35 | - [**sinuous-trace**](https://www.npmjs.com/package/sinuous-trace) ([@heyheyhello](https://github.com/heyheyhello)): Traces the internal API to record component creation, adoption, and removal. 36 | 37 | ### Examples 38 | 39 | - [**Counter**](https://codesandbox.io/s/sinuous-counter-z6k71) (@ CodeSandbox) 40 | - [**Analog SVG Clock**](https://sinuous.netlify.app/examples/clock/) ⏰ 41 | - [**Classic TodoMVC**](https://luwes.github.io/sinuous-todomvc/) _([GitHub Project](https://github.com/luwes/sinuous-todomvc))_ 42 | - [**JS Framework Benchmark**](https://github.com/krausest/js-framework-benchmark/blob/master/frameworks/keyed/sinuous/src/main.js) (@ GitHub) 43 | - [**Sierpinski Triangle**](https://replit.com/@luwes/sinuous-sierpinski-triangle-demo) 44 | - [**Three.js Boxes**](https://replit.com/@luwes/sinuous-three-boxes) 📦 45 | - [**JSX**](https://github.com/heyheyhello/sinuous-tsx-example/tree/jsx/) _([GitHub Project @heyheyhello](https://github.com/heyheyhello/sinuous-tsx-example/tree/jsx/))_ 46 | - [**TSX**](https://github.com/heyheyhello/sinuous-tsx-example/tree/tsx/) _([GitHub Project @heyheyhello](https://github.com/heyheyhello/sinuous-tsx-example/tree/tsx/))_ 47 | - [**Simple routing**](https://codesandbox.io/s/sinuous-router-g2eud) ([@mindplay-dk](https://github.com/mindplay-dk)) 🌏 48 | - [**Datepicker**](https://codesandbox.io/s/sinuous-date-picker-thxdt) ([@mindplay-dk](https://github.com/mindplay-dk)) 49 | - [**Hacker News**](https://codesandbox.io/s/sinuous-hacker-news-dqtf7) ([@mindplay-dk](https://github.com/mindplay-dk)) 50 | - [**7 GUIs**](https://codesandbox.io/s/github/theSherwood/7_GUIs/tree/master/sinuous) ([@theSherwood](https://github.com/theSherwood)) 51 | - [**Plain SPA**](https://github.com/johannschopplich/plain-spa) ([@johannschopplich](https://github.com/johannschopplich)) 52 | 53 | --- 54 | 55 | _See [complete docs](https://sinuous.netlify.app/docs/getting-started/), or in a nutshell..._ 56 | 57 | ## View syntax 58 | 59 | A goal Sinuous strives for is to have good interoperability. Sinuous creates DOM elements via **hyperscript** `h` calls. This allows the developer more freedom in the choice of the view syntax. 60 | 61 | **Hyperscript** directly call `h(type: string, props: object, ...children)`. 62 | 63 | **Tagged templates** transform the HTML to `h` calls at runtime w/ the ` html`` ` tag or, 64 | at build time with [`sinuous/babel-plugin-htm`](./src/babel-plugin-htm). 65 | 66 | **JSX** needs to be transformed at build time first with [`babel-plugin-transform-jsx-to-htm`](https://github.com/developit/htm/tree/master/packages/babel-plugin-transform-jsx-to-htm) and after with [`sinuous/babel-plugin-htm`](./packages/sinuous/babel-plugin-htm). 67 | 68 | --- 69 | 70 | **Counter Example (_1.4kB gzip_) ([Codesandbox](https://codesandbox.io/s/sinuous-counter-z6k71))** 71 | 72 | #### Tagged template (recommended) 73 | 74 | ```js 75 | import { observable, html } from 'sinuous'; 76 | 77 | const counter = observable(0); 78 | const view = () => html`
Counter ${counter}
`; 79 | 80 | document.body.append(view()); 81 | setInterval(() => counter(counter() + 1), 1000); 82 | ``` 83 | 84 | #### JSX 85 | 86 | ```jsx 87 | import { h, observable } from 'sinuous'; 88 | 89 | const counter = observable(0); 90 | const view = () =>
Counter {counter}
; 91 | 92 | document.body.append(view()); 93 | setInterval(() => counter(counter() + 1), 1000); 94 | ``` 95 | 96 | #### Hyperscript 97 | 98 | ```js 99 | import { h, observable } from 'sinuous'; 100 | 101 | const counter = observable(0); 102 | const view = () => h('div', 'Counter ', counter); 103 | 104 | document.body.append(view()); 105 | setInterval(() => counter(counter() + 1), 1000); 106 | ``` 107 | 108 | ## Reactivity 109 | 110 | The Sinuous [`observable`](./src/observable) module provides a mechanism to store and update the application state in a reactive way. If you're familiar with [S.js](https://github.com/adamhaile/S) or [Mobx](https://mobx.js.org) some functions will look very familiar, in under `1kB` Sinuous observable is not as extensive but offers a distilled version of the same functionality. It works under this philosophy: 111 | 112 | _Anything that can be derived from the application state, should be derived. Automatically._ 113 | 114 | ```js 115 | import { observable, computed, subscribe } from 'sinuous/observable'; 116 | 117 | const length = observable(0); 118 | const squared = computed(() => Math.pow(length(), 2)); 119 | 120 | subscribe(() => console.log(squared())); 121 | length(4); // => logs 16 122 | ``` 123 | 124 | #### Use a custom reactive library 125 | 126 | Sinuous can work with different observable libraries; S.js, MobX, hyperactiv. 127 | See the [wiki for more info](https://github.com/luwes/sinuous/wiki/Choose-your-own-reactive-library). 128 | 129 | ## Hydration 130 | 131 | Sinuous [`hydrate`](./src/hydrate) is a small add-on that provides fast hydration of static HTML. It's used for adding event listeners, adding dynamic attributes or content to existing DOM elements. 132 | 133 | In terms of performance nothing beats statically generated HTML, both in serving and rendering on the client. 134 | 135 | You could say using hydrate is a bit like using [jQuery](https://jquery.com/), you'll definitely write less JavaScript and do more. Additional benefits with Sinuous is that the syntax will be more _declarative_ and _reactivity_ is built-in. 136 | 137 | ```js 138 | import { observable } from 'sinuous'; 139 | import { hydrate, dhtml } from 'sinuous/hydrate'; 140 | 141 | const isActive = observable(''); 142 | 143 | hydrate( 144 | dhtml` isActive(isActive() ? '' : ' is-active')} />` 146 | ); 147 | 148 | hydrate(dhtml``); 149 | ``` 150 | 151 | ## Internal API 152 | 153 | Sinuous exposes an internal API which can be overridden for fun and profit. 154 | For example [sinuous-context](https://github.com/theSherwood/sinuous-context) uses it to implement a React like context API. 155 | 156 | As of `0.27.4` the internal API should be used to make Sinuous work with a 3rd party reactive library like [Mobx](https://mobx.js.org). This can be done by overriding `subscribe`, `root`, `sample` and `cleanup`. 157 | 158 | ### Example 159 | 160 | ```js 161 | import { api } from 'sinuous'; 162 | 163 | const oldH = api.h; 164 | api.h = (...args) => { 165 | console.log(args); 166 | return oldH(...args); 167 | }; 168 | ``` 169 | 170 | ### Methods 171 | 172 | These are defined in [sinuous/src](./src/index.js) and [sinuous/h](./src/h.js). 173 | 174 | - `h(type: string, props: object, ...children)` 175 | - `hs(type: string, props: object, ...children)` 176 | - `insert(el: Node, value: T, endMark?: Node, current?: T | Frag, startNode?: Node): T | Frag;` 177 | - `property(el: Node, value: unknown, name: string, isAttr?: boolean, isCss?: boolean): void;` 178 | - `add(parent: Node, value: Value | Value[], endMark?: Node): Node | Frag;` 179 | - `rm(parent: Node, startNode: Node, endMark: Node): void;` 180 | - `subscribe(observer: () => T): () => void;` 181 | - `root(fn: () => T): T;` 182 | - `sample(fn: () => T): T;` 183 | - `cleanup unknown>(fn: T): T;` 184 | 185 | Note that _some_ observable methods are imported into the internal API from `sinuous-observable` because they're used in Sinuous' core. To access all observable methods, import from `sinuous/observable` directly. 186 | 187 | ## Concept 188 | 189 | Sinuous started as a little experiment to get similar behavior as [Surplus](https://github.com/adamhaile/surplus) but with template literals instead of JSX. 190 | [HTM](https://github.com/developit/htm) compiles to an `h` tag. Adapted code from [Ryan Solid](https://github.com/ryansolid/babel-plugin-jsx-dom-expressions)'s dom expressions + a Reactive library provides the reactivity. 191 | 192 | Sinuous returns a [hyperscript](https://github.com/hyperhype/hyperscript) function which is armed to handle the callback functions from the reactive library and updates the DOM accordingly. 193 | 194 | ## Contributors 195 | 196 | ### Code Contributors 197 | 198 | This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. 199 | 200 | 201 | ### Financial Contributors 202 | 203 | Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/sinuous/contribute)] 204 | 205 | #### Individuals 206 | 207 | 208 | 209 | #### Organizations 210 | 211 | Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/sinuous/contribute)] 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | -------------------------------------------------------------------------------- /karma.conf.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const alias = require('@rollup/plugin-alias'); 3 | const { default: nodeResolve } = require('@rollup/plugin-node-resolve'); 4 | const commonjs = require('@rollup/plugin-commonjs'); 5 | const istanbul = require('rollup-plugin-istanbul'); 6 | const minimist = require('minimist'); 7 | const c = require('ansi-colors'); 8 | const argv = minimist(process.argv.slice(2)); 9 | 10 | var coverage = String(process.env.COVERAGE) === 'true'; 11 | 12 | module.exports = function(config) { 13 | config.set({ 14 | browsers: ['FirefoxHeadless'], 15 | 16 | // browserLogOptions: { terminal: true }, 17 | // browserConsoleLogOptions: { terminal: true }, 18 | browserConsoleLogOptions: { 19 | level: 'warn', // Filter on warn messages. 20 | format: '%b %T: %m', 21 | terminal: true 22 | }, 23 | 24 | browserNoActivityTimeout: 60 * 60 * 1000, 25 | 26 | // Use only one browser, works better with open source Sauce Labs remote testing 27 | // concurrency: 1, 28 | 29 | captureTimeout: 0, 30 | 31 | // level of logging 32 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 33 | logLevel: config.LOG_DISABLE, 34 | 35 | client: { captureConsole: !!argv.console }, 36 | 37 | // test results reporter to use 38 | // possible values: 'dots', 'progress' 39 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 40 | reporters: ['tap-pretty'].concat( 41 | coverage ? 'coverage' : [] 42 | ), 43 | 44 | tapReporter: { 45 | prettify: require('faucet') // require('tap-spec') 46 | }, 47 | 48 | formatError(msg) { 49 | msg = msg.replace(/\([^<]+/gm, ''); 50 | msg = msg.replace(/(\bat\s.*)/gms, argv.stack ? c.dim('$1') : ''); 51 | return msg; 52 | }, 53 | 54 | coverageReporter: { 55 | dir: path.join(__dirname, 'coverage'), 56 | reporters: [ 57 | { type: 'text' }, 58 | { type: 'html' }, 59 | { type: 'lcovonly', subdir: '.', file: 'lcov.info' } 60 | ] 61 | }, 62 | 63 | frameworks: ['tap'], 64 | 65 | files: [ 66 | { 67 | pattern: config.grep || 'test/test.js', 68 | watched: false 69 | }, 70 | ], 71 | 72 | preprocessors: { 73 | 'test/test.js': ['rollup'] 74 | }, 75 | 76 | rollupPreprocessor: { 77 | plugins: [ 78 | alias({ 79 | entries: { 80 | tape: 'tape-browser', 81 | 'sinuous/h': __dirname + '/src/h.js', 82 | 'sinuous/htm': __dirname + '/src/htm.js', 83 | 'sinuous/observable': __dirname + '/src/observable.js', 84 | 'sinuous/template': __dirname + '/src/template.js', 85 | 'sinuous/hydrate': __dirname + '/src/hydrate.js', 86 | 'sinuous/map': __dirname + '/src/map.js', 87 | 'sinuous': __dirname + '/src/index.js' 88 | } 89 | }), 90 | nodeResolve(), 91 | commonjs(), 92 | istanbul({ 93 | include: config.grep ? 94 | config.grep.replace('/test/', '/src/') : 95 | '*/!(htm)/**/src/**/*.js' 96 | }), 97 | ].filter(Boolean), 98 | onwarn: (msg) => /eval/.test(msg) && void 0 99 | } 100 | }); 101 | }; 102 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sinuous", 3 | "version": "0.32.1", 4 | "description": "🧬 Small, fast, reactive render engine", 5 | "module": "src/index.js", 6 | "main": "src/index.js", 7 | "types": "src/index.d.ts", 8 | "type": "module", 9 | "exports": { 10 | ".": { 11 | "import": "./src/index.js" 12 | }, 13 | "./h": { 14 | "import": "./src/h.js" 15 | }, 16 | "./babel-plugin-htm": { 17 | "import": "./src/babel-plugin-htm.js", 18 | "require": "./dist/babel-plugin-htm.cjs" 19 | }, 20 | "./htm": { 21 | "import": "./src/htm.js" 22 | }, 23 | "./hydrate": { 24 | "import": "./src/hydrate.js" 25 | }, 26 | "./map": { 27 | "import": "./src/map.js" 28 | }, 29 | "./observable": { 30 | "import": "./src/observable.js" 31 | }, 32 | "./template": { 33 | "import": "./src/template.js" 34 | } 35 | }, 36 | "files": [ 37 | "src", 38 | "dist", 39 | "!**/test/**" 40 | ], 41 | "scripts": { 42 | "lint": "eslint 'src/*.js'", 43 | "test": "cross-env COVERAGE=true npm run test:htm && karma start karma.conf.cjs", 44 | "test:htm": "tape -r esm test/htm/*.js | faucet", 45 | "build": "rollup -i src/babel-plugin-htm.js -f cjs -o dist/babel-plugin-htm.cjs", 46 | "prepublishOnly": "npm run lint" 47 | }, 48 | "eslintConfig": { 49 | "root": true, 50 | "env": { 51 | "browser": true, 52 | "es6": true, 53 | "node": true 54 | }, 55 | "extends": [ 56 | "eslint:recommended", 57 | "plugin:import/warnings" 58 | ], 59 | "parserOptions": { 60 | "ecmaVersion": 9, 61 | "sourceType": "module" 62 | }, 63 | "rules": { 64 | "semi": "error", 65 | "no-unused-vars": [ 66 | "error", 67 | { 68 | "varsIgnorePattern": "^hs?|ds?$" 69 | } 70 | ] 71 | } 72 | }, 73 | "repository": "luwes/sinuous", 74 | "keywords": [ 75 | "functional", 76 | "reactive", 77 | "declarative" 78 | ], 79 | "author": "Wesley Luyten (https://wesleyluyten.com)", 80 | "license": "MIT", 81 | "bugs": { 82 | "url": "https://github.com/luwes/sinuous/issues" 83 | }, 84 | "homepage": "https://github.com/luwes/sinuous#readme", 85 | "devDependencies": { 86 | "@rollup/plugin-alias": "^4.0.3", 87 | "@rollup/plugin-commonjs": "^24.0.1", 88 | "@rollup/plugin-node-resolve": "^15.0.1", 89 | "ansi-colors": "^4.1.3", 90 | "cross-env": "^7.0.3", 91 | "eslint": "^8.33.0", 92 | "eslint-plugin-import": "^2.27.5", 93 | "esm": "^3.2.25", 94 | "faucet": "^0.0.3", 95 | "ispy": "^0.1.2", 96 | "karma": "6.4.1", 97 | "karma-coverage": "2.2.0", 98 | "karma-firefox-launcher": "^2.1.2", 99 | "karma-rollup-preprocessor": "7.0.7", 100 | "karma-tap": "^4.2.0", 101 | "karma-tap-pretty-reporter": "^4.2.0", 102 | "minimist": "^1.2.7", 103 | "rollup": "^3.13.0", 104 | "rollup-plugin-istanbul": "^4.0.0", 105 | "tape": "5.6.3", 106 | "tape-browser": "^4.13.0", 107 | "typescript": "^4.9.5" 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | rules: 2 | no-var: off 3 | no-cond-assign: off 4 | no-negated-condition: error 5 | no-constant-condition: off 6 | -------------------------------------------------------------------------------- /src/babel-plugin-htm.js: -------------------------------------------------------------------------------- 1 | import { build, treeify } from './htm.js'; 2 | 3 | /** 4 | * @param {{ types: import('@babel/types') }} babel 5 | * @param {object} options 6 | * @param {string | false} [options.pragma=h] JSX/hyperscript pragma. 7 | * @param {string} [options.tag=html] The tagged template "tag" function name to process. 8 | * @param {string | boolean | object} [options.import=false] Import the tag automatically 9 | * @param {boolean} [options.monomorphic=false] Output monomorphic inline objects instead of using String literals. 10 | * @param {boolean} [options.useBuiltIns=false] Use the native Object.assign instead of trying to polyfill it. 11 | * @param {boolean} [options.useNativeSpread=false] Use the native { ...a, ...b } syntax for prop spreads. 12 | * @param {boolean} [options.variableArity=true] If `false`, always passes exactly 3 arguments to the pragma function. 13 | * @param {boolean} [options.wrapExpression=''] If set wraps the generated expression with a function passing the same arguments the tagged template would receive. 14 | */ 15 | export default function htmBabelPlugin({ types: t }, options = {}) { 16 | const pragmaString = options.pragma === false ? false : options.pragma || 'h'; 17 | const pragma = pragmaString === false ? false : dottedIdentifier(pragmaString); 18 | const useBuiltIns = options.useBuiltIns; 19 | const useNativeSpread = options.useNativeSpread; 20 | const inlineVNodes = options.monomorphic || pragma === false; 21 | const importDeclaration = pragmaImport(options.import || false); 22 | const wrapExpression = options.wrapExpression; 23 | let fields; 24 | 25 | function pragmaImport(imp) { 26 | if (pragmaString === false || imp === false) { 27 | return null; 28 | } 29 | const pragmaRoot = t.identifier(pragmaString.split('.')[0]); 30 | // eslint-disable-next-line 31 | const { module, export: export_ } = typeof imp !== 'string' ? imp : { 32 | module: imp, 33 | export: null 34 | }; 35 | 36 | let specifier; 37 | if (export_ === '*') { 38 | specifier = t.importNamespaceSpecifier(pragmaRoot); 39 | } 40 | else if (export_ === 'default') { 41 | specifier = t.importDefaultSpecifier(pragmaRoot); 42 | } 43 | else { 44 | specifier = t.importSpecifier(pragmaRoot, export_ ? t.identifier(export_) : pragmaRoot); 45 | } 46 | return t.importDeclaration([specifier], t.stringLiteral(module)); 47 | } 48 | 49 | function dottedIdentifier(keypath) { 50 | const path = keypath.split('.'); 51 | let out; 52 | for (let i = 0; i < path.length; i++) { 53 | const ident = propertyName(path[i]); 54 | out = i === 0 ? ident : t.memberExpression(out, ident); 55 | } 56 | return out; 57 | } 58 | 59 | function patternStringToRegExp(str) { 60 | const parts = str.split('/').slice(1); 61 | const end = parts.pop() || ''; 62 | return new RegExp(parts.join('/'), end); 63 | } 64 | 65 | function propertyName(key) { 66 | if (t.isValidIdentifier(key)) { 67 | return t.identifier(key); 68 | } 69 | return t.stringLiteral(key); 70 | } 71 | 72 | function objectProperties(obj) { 73 | return Object.keys(obj).map(key => { 74 | const values = obj[key].map(valueOrNode => 75 | t.isNode(valueOrNode) ? maybeField(valueOrNode) : t.valueToNode(valueOrNode) 76 | ); 77 | 78 | let node = values[0]; 79 | if (values.length > 1) { 80 | if (!t.isStringLiteral(node)) { 81 | node = t.binaryExpression('+', t.stringLiteral(''), concatFunctionNode(node)); 82 | } 83 | values.slice(1).forEach(value => { 84 | node = t.binaryExpression('+', node, concatFunctionNode(value)); 85 | }); 86 | if (values.some(isFunctionLike)) { 87 | node = t.functionExpression(null, [], t.blockStatement([ 88 | t.returnStatement(node) 89 | ])); 90 | } 91 | } 92 | 93 | return t.objectProperty(propertyName(key), node); 94 | }); 95 | } 96 | 97 | function stringValue(str) { 98 | if (options.monomorphic) { 99 | return t.objectExpression([ 100 | t.objectProperty(propertyName('type'), t.numericLiteral(3)), 101 | t.objectProperty(propertyName('tag'), t.nullLiteral()), 102 | t.objectProperty(propertyName('props'), t.nullLiteral()), 103 | t.objectProperty(propertyName('children'), t.nullLiteral()), 104 | t.objectProperty(propertyName('text'), t.stringLiteral(str)) 105 | ]); 106 | } 107 | return t.stringLiteral(str); 108 | } 109 | 110 | function createVNode(tag, props, children) { 111 | // Never pass children=[[]]. 112 | if ( 113 | children.elements.length === 1 && 114 | t.isArrayExpression(children.elements[0]) && 115 | children.elements[0].elements.length === 0 116 | ) { 117 | children = children.elements[0]; 118 | } 119 | 120 | if (inlineVNodes) { 121 | return t.objectExpression([ 122 | options.monomorphic && t.objectProperty(propertyName('type'), t.numericLiteral(1)), 123 | t.objectProperty(propertyName('tag'), tag), 124 | t.objectProperty(propertyName('props'), props), 125 | t.objectProperty(propertyName('children'), children), 126 | options.monomorphic && t.objectProperty(propertyName('text'), t.nullLiteral()) 127 | ].filter(Boolean)); 128 | } 129 | 130 | // Passing `{variableArity:false}` always produces `h(tag, props, children)` - where `children` is always an Array. 131 | // Otherwise, the default is `h(tag, props, ...children)`. 132 | if (options.variableArity !== false) { 133 | children = children.elements; 134 | } 135 | 136 | return t.callExpression(pragma, [tag, props].concat(children)); 137 | } 138 | 139 | function spreadNode(args, state) { 140 | if (!args || args.length === 0) { 141 | return t.nullLiteral(); 142 | } 143 | if (args.length > 0 && t.isNode(args[0])) { 144 | args.unshift({}); 145 | } 146 | 147 | // 'Object.assign(x)', can be collapsed to 'x'. 148 | if (args.length === 1) { 149 | return propsNode(args[0]); 150 | } 151 | // 'Object.assign({}, x)', can be collapsed to 'x'. 152 | if (args.length === 2 && !t.isNode(args[0]) && Object.keys(args[0]).length === 0) { 153 | return propsNode(args[1]); 154 | } 155 | 156 | if (useNativeSpread) { 157 | const properties = []; 158 | args.forEach(arg => { 159 | if (t.isNode(arg)) { 160 | properties.push(t.spreadElement(arg)); 161 | } 162 | else { 163 | properties.push(...objectProperties(arg)); 164 | } 165 | }); 166 | return t.objectExpression(properties); 167 | } 168 | 169 | const helper = useBuiltIns ? dottedIdentifier('Object.assign') : state.addHelper('extends'); 170 | return t.callExpression(helper, args.map(propsNode)); 171 | } 172 | 173 | function propsNode(props) { 174 | return t.isNode(props) ? maybeField(props) : t.objectExpression(objectProperties(props)); 175 | } 176 | 177 | function transform(node, state) { 178 | node = maybeField(node); 179 | 180 | if (t.isNode(node)) return node; 181 | if (typeof node === 'string') return stringValue(node); 182 | if (node === undefined) return t.identifier('undefined'); 183 | 184 | const { tag, props, children } = node; 185 | const isComponent = typeof tag !== 'string'; 186 | const newTag = isComponent ? tag : t.stringLiteral(tag); 187 | const newProps = spreadNode(props, state); 188 | const newChildren = t.arrayExpression((children || []) 189 | .map(child => transform(child, state)) 190 | .map(child => isComponent ? t.arrowFunctionExpression([], child) : child)); 191 | return createVNode(newTag, newProps, newChildren); 192 | } 193 | 194 | function maybeField(node) { 195 | if (fields.has(node)) { 196 | return fields.get(node); 197 | } 198 | return node; 199 | } 200 | 201 | function isFunctionLike(node) { 202 | return ( 203 | t.isIdentifier(node) || 204 | t.isFunctionExpression(node) || 205 | t.isArrowFunctionExpression(node) 206 | ); 207 | } 208 | 209 | function concatFunctionNode(node) { 210 | if (isFunctionLike(node)) { 211 | const typeofNode = t.unaryExpression('typeof', node); 212 | const isNodeFunction = t.binaryExpression('===', typeofNode, t.stringLiteral('function')); 213 | return t.conditionalExpression(isNodeFunction, t.callExpression(t.memberExpression(node, t.identifier('call')), [t.thisExpression()]), node); 214 | } 215 | return node; 216 | } 217 | 218 | // The tagged template tag function name we're looking for. 219 | // This is static because it's generally assigned via htm.bind(h), 220 | // which could be imported from elsewhere, making tracking impossible. 221 | const htmlName = options.tag || 'html'; 222 | return { 223 | name: 'htm', 224 | visitor: { 225 | Program: { 226 | exit(path, state) { 227 | if (state.get('hasHtm') && importDeclaration) { 228 | path.unshiftContainer('body', importDeclaration); 229 | } 230 | }, 231 | }, 232 | TaggedTemplateExpression(path, state) { 233 | fields = new Map(); 234 | 235 | const tag = path.node.tag.name; 236 | if (htmlName[0] === '/' ? patternStringToRegExp(htmlName).test(tag) : tag === htmlName) { 237 | const statics = path.node.quasi.quasis.map(e => e.value.raw); 238 | const exprs = path.node.quasi.expressions; 239 | 240 | let tree = treeify(build(statics), exprs); 241 | 242 | // Turn array expression in Array so it can be converted below 243 | // to a pragma call expression for fragments. 244 | if (t.isArrayExpression(tree)) { 245 | tree = tree.elements; 246 | } 247 | 248 | if (wrapExpression) { 249 | exprs.forEach(expr => { 250 | fields.set(expr, path.scope.generateUidIdentifier("field")); 251 | }); 252 | } 253 | 254 | let node = Array.isArray(tree) 255 | ? t.callExpression(pragma, [ 256 | t.arrayExpression(tree.map(root => transform(root, state))) 257 | ]) 258 | : t.isNode(tree) || typeof tree === 'string' 259 | ? t.callExpression(pragma, [ 260 | t.arrayExpression([transform(tree, state)]) 261 | ]) 262 | : transform(tree, state); 263 | 264 | if (wrapExpression) { 265 | let taggedArgs = Array.from(fields.values()); 266 | taggedArgs.unshift(path.scope.generateUidIdentifier("statics")); 267 | 268 | node = t.callExpression(dottedIdentifier(`${wrapExpression}.apply`), [ 269 | t.arrowFunctionExpression(taggedArgs, node), 270 | t.arrayExpression([ 271 | t.arrayExpression(statics.map(str => t.stringLiteral(str))), 272 | ...exprs 273 | ]) 274 | ]); 275 | } 276 | path.replaceWith(node); 277 | state.set('hasHtm', true); 278 | } 279 | } 280 | } 281 | }; 282 | } 283 | -------------------------------------------------------------------------------- /src/babel-plugin-htm.md: -------------------------------------------------------------------------------- 1 | # `babel-plugin-htm` 2 | 3 | A Babel plugin that compiles [htm] syntax to hyperscript, React.createElement, 4 | or just plain objects. 5 | 6 | This is a fork of the official plugin. You can compare the changes by diffing 7 | against the official [v3.0.0 release]. 8 | 9 | Documentation is in the readme on [babel-plugin-html]'s repo. 10 | 11 | [htm]: https://github.com/developit/htm 12 | [v3.0.0 release]: https://github.com/developit/htm/blob/3.0.0/packages/babel-plugin-htm/index.mjs 13 | [babel-plugin-html]: https://github.com/developit/htm/tree/master/packages/babel-plugin-htm 14 | -------------------------------------------------------------------------------- /src/h.d.ts: -------------------------------------------------------------------------------- 1 | import { JSXInternal } from "./jsx"; 2 | import { ElementChildren, FunctionComponent } from "./shared"; 3 | 4 | import { subscribe } from "./observable"; 5 | 6 | declare namespace _h { 7 | function h( 8 | type: string, 9 | props: 10 | | (JSXInternal.HTMLAttributes | JSXInternal.SVGAttributes) & 11 | Record 12 | | null, 13 | ...children: ElementChildren[] 14 | ): HTMLElement | SVGElement; 15 | function h( 16 | type: FunctionComponent, 17 | props: 18 | | (JSXInternal.HTMLAttributes | JSXInternal.SVGAttributes) & 19 | Record 20 | | null, 21 | ...children: ElementChildren[] 22 | ): HTMLElement | SVGElement | DocumentFragment; 23 | function h( 24 | tag: ElementChildren[] | [], 25 | ...children: ElementChildren[] 26 | ): DocumentFragment; 27 | namespace h { 28 | export import JSX = JSXInternal; 29 | } 30 | } 31 | 32 | type Frag = { _startMark: Text } 33 | type Value = Node | DocumentFragment | string | number 34 | 35 | export interface HyperscriptApi { 36 | // Hyperscript 37 | h: typeof _h.h; 38 | 39 | // Internal API 40 | s: Boolean; 41 | insert(el: Node, value: T, endMark?: Node, current?: T | Frag, startNode?: Node): T | Frag; 42 | property(el: Node, value: unknown, name: string, isAttr?: boolean, isCss?: boolean): void; 43 | add(parent: Node, value: Value | Value[], endMark?: Node): Node | Frag; 44 | rm(parent: Node, startNode: Node, endMark: Node): void; 45 | 46 | // Required from an observable implmentation 47 | subscribe: typeof subscribe; 48 | } 49 | 50 | export const api: HyperscriptApi; 51 | -------------------------------------------------------------------------------- /src/h.js: -------------------------------------------------------------------------------- 1 | /* Adapted from Hyper DOM Expressions - The MIT License - Ryan Carniato */ 2 | 3 | /** 4 | * Internal API. 5 | * Consumer must provide an observable at api.subscribe(observer: () => T). 6 | * 7 | * @typedef {boolean} hSVG Determines if `h` will build HTML or SVG elements 8 | * @type {{ 9 | * h: import('./h.js').hTag 10 | * s: hSVG 11 | * insert: import('./insert.js').hInsert 12 | * property: import('./property.js').hProperty 13 | * add: import('./add.js').hAdd 14 | * rm: import('./remove-nodes.js').hRemoveNodes 15 | * subscribe: (observer: () => *) => void 16 | * }} 17 | */ 18 | // @ts-ignore Object is populated in index.js 19 | export const api = {}; 20 | 21 | /** @type {[]} Instead of `any[]` */ 22 | const EMPTY_ARR = []; 23 | 24 | /** @type {(value: *) => Text | Node | DocumentFragment} */ 25 | const castNode = (value) => { 26 | if (typeof value === 'string') { 27 | return document.createTextNode(value); 28 | } 29 | // Note that a DocumentFragment is an instance of Node 30 | if (!(value instanceof Node)) { 31 | // Passing an empty array creates a DocumentFragment 32 | // Note this means api.add is not purely a subcall of api.h; it can nest 33 | return api.h(EMPTY_ARR, value); 34 | } 35 | return value; 36 | }; 37 | 38 | /** 39 | * @typedef {{ _startMark: Text }} Frag 40 | * @type {(value: Text | Node | DocumentFragment) => (Node | Frag)?} 41 | */ 42 | const frag = (value) => { 43 | const { childNodes } = value; 44 | if (!childNodes || value.nodeType !== 11) return; 45 | if (childNodes.length < 2) return childNodes[0]; 46 | // For a fragment of 2 elements or more add a startMark. This is required for 47 | // multiple nested conditional computeds that return fragments. 48 | 49 | // It looks recursive here but the next call's fragOrNode is only Text('') 50 | return { 51 | _startMark: /** @type {Text} */ (api.add(value, '', childNodes[0])), 52 | }; 53 | }; 54 | 55 | /** 56 | * Add a string or node before a reference node or at the end. 57 | * @typedef {Node | string | number} Value 58 | * @typedef {(parent: Node, value: Value | Value[], endMark: Node?) => Node | Frag} hAdd 59 | * @type {hAdd} 60 | */ 61 | export const add = (parent, value, endMark) => { 62 | value = castNode(value); 63 | const fragOrNode = frag(value) || value; 64 | 65 | // If endMark is `null`, value will be added to the end of the list. 66 | parent.insertBefore(value, endMark && endMark.parentNode && endMark); 67 | return fragOrNode; 68 | }; 69 | 70 | /** 71 | * @typedef {import('./add.js').Frag} Frag 72 | * @typedef {(el: Node, value: *, endMark: Node?, current: (Node | Frag)?, 73 | * startNode: Node?) => Node | Frag } hInsert 74 | * @type {hInsert} 75 | */ 76 | export const insert = (el, value, endMark, current, startNode) => { 77 | // This is needed if the el is a DocumentFragment initially. 78 | el = (endMark && endMark.parentNode) || el; 79 | 80 | // Save startNode of current. In clear() endMark.previousSibling is not always 81 | // accurate if content gets pulled before clearing. 82 | startNode = startNode || (current instanceof Node && current); 83 | 84 | // @ts-ignore Allow empty if statement 85 | if (value === current); 86 | else if ( 87 | (!current || typeof current === 'string') && 88 | // @ts-ignore Doesn't like `value += ''` 89 | // eslint-disable-next-line no-implicit-coercion 90 | (typeof value === 'string' || (typeof value === 'number' && (value += ''))) 91 | ) { 92 | // Block optimized for string insertion. 93 | // eslint-disable-next-line eqeqeq 94 | if (current == null || !el.firstChild) { 95 | if (endMark) { 96 | api.add(el, value, endMark); 97 | } else { 98 | // Using textContent is a lot faster than append -> createTextNode. 99 | el.textContent = /** @type {string} See `value += '' */ (value); 100 | } 101 | } else { 102 | if (endMark) { 103 | (endMark.previousSibling || el.lastChild).data = value; 104 | } else { 105 | el.firstChild.data = value; 106 | } 107 | } 108 | current = value; 109 | } else if (typeof value === 'function') { 110 | api.subscribe(() => { 111 | current = api.insert( 112 | el, 113 | value.call({ el, endMark }), 114 | endMark, 115 | current, 116 | startNode 117 | ); 118 | }); 119 | } else { 120 | // Block for nodes, fragments, Arrays, non-stringables and node -> stringable. 121 | if (endMark) { 122 | // `current` can't be `0`, it's coerced to a string in insert. 123 | if (current) { 124 | if (!startNode) { 125 | // Support fragments 126 | startNode = 127 | (current._startMark && current._startMark.nextSibling) || 128 | endMark.previousSibling; 129 | } 130 | api.rm(el, startNode, endMark); 131 | } 132 | } else { 133 | el.textContent = ''; 134 | } 135 | current = null; 136 | 137 | if (value && value !== true) { 138 | current = api.add(el, value, endMark); 139 | } 140 | } 141 | 142 | return current; 143 | }; 144 | 145 | /** 146 | * Proxy an event to hooked event handlers. 147 | * @this Node & { _listeners: { [name: string]: (ev: Event) => * } } 148 | * @type {(e: Event) => *} 149 | */ 150 | function eventProxy(e) { 151 | return this._listeners && this._listeners[e.type](e); 152 | } 153 | 154 | /** 155 | * @type {(el: Node, name: string, value: (ev: Event?) => *) => void} 156 | */ 157 | const handleEvent = (el, name, value) => { 158 | name = name.slice(2).toLowerCase(); 159 | 160 | if (value) { 161 | el.addEventListener(name, eventProxy); 162 | } else { 163 | el.removeEventListener(name, eventProxy); 164 | } 165 | 166 | (el._listeners || (el._listeners = {}))[name] = value; 167 | }; 168 | 169 | /** 170 | * @typedef {(el: Node, value: *, name: string, isAttr: boolean?, isCss: boolean?) => void} hProperty 171 | * @type {hProperty} 172 | */ 173 | export const property = (el, value, name, isAttr, isCss) => { 174 | // eslint-disable-next-line eqeqeq 175 | if (value == null) return; 176 | if (!name || (name === 'attrs' && (isAttr = true))) { 177 | for (name in value) { 178 | api.property(el, value[name], name, isAttr, isCss); 179 | } 180 | } else if (name[0] === 'o' && name[1] === 'n' && !value.$o) { 181 | // Functions added as event handlers are not executed 182 | // on render unless they have an observable indicator. 183 | handleEvent(el, name, value); 184 | } else if (typeof value === 'function') { 185 | api.subscribe(() => { 186 | api.property(el, value.call({ el, name }), name, isAttr, isCss); 187 | }); 188 | } else if (isCss) { 189 | el.style.setProperty(name, value); 190 | } else if ( 191 | isAttr || 192 | name.slice(0, 5) === 'data-' || 193 | name.slice(0, 5) === 'aria-' 194 | ) { 195 | el.setAttribute(name, value); 196 | } else if (name === 'style') { 197 | if (typeof value === 'string') { 198 | el.style.cssText = value; 199 | } else { 200 | api.property(el, value, null, isAttr, true); 201 | } 202 | } else { 203 | if (name === 'class') name += 'Name'; 204 | el[name] = value; 205 | } 206 | }; 207 | 208 | /** 209 | * Removes nodes, starting from `startNode` (inclusive) to `endMark` (exclusive). 210 | * @typedef {(parent: Node, startNode: Node, endMark: Node) => void} hRemoveNodes 211 | * @type {hRemoveNodes} 212 | */ 213 | export const removeNodes = (parent, startNode, endMark) => { 214 | while (startNode && startNode !== endMark) { 215 | const n = startNode.nextSibling; 216 | // Is needed in case the child was pulled out the parent before clearing. 217 | if (parent === startNode.parentNode) { 218 | parent.removeChild(startNode); 219 | } 220 | startNode = n; 221 | } 222 | }; 223 | 224 | /** 225 | * Sinuous `h` tag aka hyperscript. 226 | * @typedef {HTMLElement | SVGElement | DocumentFragment} DOM 227 | * @typedef {(tag: string? | [], props: object?, ...children: Node | *) => DOM} hTag 228 | * @type {hTag} 229 | */ 230 | 231 | export const h = (...args) => { 232 | let el; 233 | const item = (/** @type {*} */ arg) => { 234 | // @ts-ignore Allow empty if 235 | // eslint-disable-next-line eqeqeq 236 | if (arg == null); 237 | else if (typeof arg === 'string') { 238 | if (el) { 239 | api.add(el, arg); 240 | } else { 241 | el = api.s 242 | ? document.createElementNS('http://www.w3.org/2000/svg', arg) 243 | : document.createElement(arg); 244 | } 245 | } else if (Array.isArray(arg)) { 246 | // Support Fragments 247 | if (!el) el = document.createDocumentFragment(); 248 | arg.forEach(item); 249 | } else if (arg instanceof Node) { 250 | if (el) { 251 | api.add(el, arg); 252 | } else { 253 | // Support updates 254 | el = arg; 255 | } 256 | } else if (typeof arg === 'object') { 257 | // @ts-ignore 0 | 1 is a boolean but can't type cast; they don't overlap 258 | api.property(el, arg, null, api.s); 259 | } else if (typeof arg === 'function') { 260 | if (el) { 261 | // See note in add.js#frag() - This is a Text('') node 262 | const endMark = /** @type {Text} */ (api.add(el, '')); 263 | api.insert(el, arg, endMark); 264 | } else { 265 | // Support Components 266 | el = arg.apply(null, args.splice(1)); 267 | } 268 | } else { 269 | // eslint-disable-next-line no-implicit-coercion,prefer-template 270 | api.add(el, '' + arg); 271 | } 272 | }; 273 | args.forEach(item); 274 | return el; 275 | }; 276 | 277 | 278 | api.h = h; 279 | api.insert = insert; 280 | api.property = property; 281 | api.add = add; 282 | api.rm = removeNodes; 283 | -------------------------------------------------------------------------------- /src/htm.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | 14 | export const MINI = false; 15 | 16 | const MODE_SLASH = 0; 17 | const MODE_TEXT = 1; 18 | const MODE_WHITESPACE = 2; 19 | const MODE_TAGNAME = 3; 20 | const MODE_COMMENT = 4; 21 | const MODE_PROP_SET = 5; 22 | const MODE_PROP_APPEND = 6; 23 | 24 | const TAG_SET = 1; 25 | const CHILD_APPEND = 0; 26 | const CHILD_RECURSE = 2; 27 | const PROPS_ASSIGN = 3; 28 | const PROP_SET = MODE_PROP_SET; 29 | const PROP_APPEND = MODE_PROP_APPEND; 30 | 31 | // Turn a result of a build(...) call into a tree that is more 32 | // convenient to analyze and transform (e.g. Babel plugins). 33 | // For example: 34 | // treeify( 35 | // build'
<${x} />
`, 36 | // [X, Y, Z] 37 | // ) 38 | // returns: 39 | // { 40 | // tag: 'div', 41 | // props: [ { href: ["1", X] }, Y ], 42 | // children: [ { tag: Z, props: [], children: [] } ] 43 | // } 44 | export const treeify = (built, fields) => { 45 | const _treeify = built => { 46 | let tag = ''; 47 | let currentProps = null; 48 | const props = []; 49 | const children = []; 50 | 51 | for (let i = 1; i < built.length; i++) { 52 | const field = built[i++]; 53 | const value = typeof field === 'number' ? fields[field - 1] : field; 54 | 55 | if (built[i] === TAG_SET) { 56 | tag = value; 57 | } 58 | else if (built[i] === PROPS_ASSIGN) { 59 | props.push(value); 60 | currentProps = null; 61 | } 62 | else if (built[i] === PROP_SET) { 63 | if (!currentProps) { 64 | currentProps = Object.create(null); 65 | props.push(currentProps); 66 | } 67 | currentProps[built[++i]] = [value]; 68 | } 69 | else if (built[i] === PROP_APPEND) { 70 | currentProps[built[++i]].push(value); 71 | } 72 | else if (built[i] === CHILD_RECURSE) { 73 | children.push(_treeify(value)); 74 | } 75 | else if (built[i] === CHILD_APPEND) { 76 | children.push(value); 77 | } 78 | } 79 | 80 | return { tag, props, children }; 81 | }; 82 | const { children } = _treeify(built); 83 | return children.length > 1 ? children : children[0]; 84 | }; 85 | 86 | 87 | export const evaluate = (h, built, fields, args) => { 88 | let propBody = {}; 89 | for (let i = 1; i < built.length; i++) { 90 | const field = built[i]; 91 | const value = typeof field === 'number' ? fields[field] : field; 92 | const type = built[++i]; 93 | 94 | if (type === TAG_SET) { 95 | args[0] = value; 96 | } 97 | else if (type === PROPS_ASSIGN) { 98 | args[1] = Object.assign(args[1] || {}, value); 99 | } 100 | else if (type === PROP_SET) { 101 | (args[1] = args[1] || {})[built[++i]] = value; 102 | } 103 | else if (type === PROP_APPEND) { 104 | let key = built[++i]; 105 | let prev = (args[1] = args[1] || {})[key]; 106 | let parts = propBody[key]; 107 | 108 | if (!parts && (typeof value === 'function' || typeof prev === 'function')) { 109 | parts = (prev && [prev]) || []; 110 | 111 | args[1][key] = function() { 112 | let prop = ''; 113 | for (var j = 0; j < parts.length; j++) { 114 | prop += typeof parts[j] === 'function' ? parts[j].call(this) : parts[j]; 115 | } 116 | return prop; 117 | }; 118 | } 119 | 120 | if (parts) { 121 | parts.push(value); 122 | } else { 123 | args[1][key] += (value + ''); 124 | } 125 | } 126 | else if (type) { 127 | // code === CHILD_RECURSE 128 | const result = () => h.apply(null, evaluate(h, value, fields, ['', null])); 129 | 130 | // if it's a component we pass the children with closure so the 131 | // component is executed before the children of that component. 132 | args.push(typeof args[0] === 'function' ? result : result()); 133 | } 134 | else { 135 | // code === CHILD_APPEND 136 | args.push(value); 137 | } 138 | } 139 | 140 | return args; 141 | }; 142 | 143 | export const build = function(statics) { 144 | const fields = arguments; 145 | const h = this; 146 | 147 | let mode = MODE_TEXT; 148 | let buffer = ''; 149 | let quote = ''; 150 | let current = [0]; 151 | let char, propName; 152 | 153 | const commit = field => { 154 | if (mode === MODE_TEXT && (field || (buffer = buffer.replace(/^\s*\n\s*|\s*\n\s*$/g,'')))) { 155 | if (MINI) { 156 | current.push(field ? fields[field] : buffer); 157 | } 158 | else { 159 | current.push(field || buffer, CHILD_APPEND); 160 | } 161 | } 162 | else if (mode === MODE_TAGNAME && (field || buffer)) { 163 | if (MINI) { 164 | current[1] = field ? fields[field] : buffer; 165 | } 166 | else { 167 | current.push(field || buffer, TAG_SET); 168 | } 169 | mode = MODE_WHITESPACE; 170 | } 171 | else if (mode === MODE_WHITESPACE && buffer === '...' && field) { 172 | if (MINI) { 173 | current[2] = Object.assign(current[2] || {}, fields[field]); 174 | } 175 | else { 176 | current.push(field, PROPS_ASSIGN); 177 | } 178 | } 179 | else if (mode === MODE_WHITESPACE && buffer && !field) { 180 | if (MINI) { 181 | (current[2] = current[2] || {})[buffer] = true; 182 | } 183 | else { 184 | current.push(true, PROP_SET, buffer); 185 | } 186 | } 187 | else if (mode >= MODE_PROP_SET) { 188 | if (MINI) { 189 | if (mode === MODE_PROP_SET) { 190 | (current[2] = current[2] || {})[propName] = field ? buffer ? (buffer + fields[field]) : fields[field] : buffer; 191 | mode = MODE_PROP_APPEND; 192 | } 193 | else if (field || buffer) { 194 | current[2][propName] += field ? buffer + fields[field] : buffer; 195 | } 196 | } 197 | else { 198 | if (buffer || (!field && mode === MODE_PROP_SET)) { 199 | current.push(buffer, mode, propName); 200 | mode = MODE_PROP_APPEND; 201 | } 202 | if (field) { 203 | current.push(field, mode, propName); 204 | mode = MODE_PROP_APPEND; 205 | } 206 | } 207 | } 208 | 209 | buffer = ''; 210 | }; 211 | 212 | for (let i=0; i' 241 | if (buffer === '--' && char === '>') { 242 | mode = MODE_TEXT; 243 | buffer = ''; 244 | } 245 | else { 246 | buffer = char + buffer[0]; 247 | } 248 | } 249 | else if (quote) { 250 | if (char === quote) { 251 | quote = ''; 252 | } 253 | else { 254 | buffer += char; 255 | } 256 | } 257 | else if (char === '"' || char === "'") { 258 | quote = char; 259 | } 260 | else if (char === '>') { 261 | commit(); 262 | mode = MODE_TEXT; 263 | } 264 | else if (!mode) { 265 | // Ignore everything until the tag ends 266 | } 267 | else if (char === '=') { 268 | mode = MODE_PROP_SET; 269 | propName = buffer; 270 | buffer = ''; 271 | } 272 | else if (char === '/' && (mode < MODE_PROP_SET || statics[i][j+1] === '>')) { 273 | commit(); 274 | if (mode === MODE_TAGNAME) { 275 | current = current[0]; 276 | } 277 | mode = current; 278 | if (MINI) { 279 | (current = current[0]).push(h.apply(null, mode.slice(1))); 280 | } 281 | else { 282 | (current = current[0]).push(mode, CHILD_RECURSE); 283 | } 284 | mode = MODE_SLASH; 285 | } 286 | else if (char === ' ' || char === '\t' || char === '\n' || char === '\r') { 287 | // 288 | commit(); 289 | mode = MODE_WHITESPACE; 290 | } 291 | else { 292 | buffer += char; 293 | } 294 | 295 | if (mode === MODE_TAGNAME && buffer === '!--') { 296 | mode = MODE_COMMENT; 297 | current = current[0]; 298 | } 299 | } 300 | } 301 | commit(); 302 | 303 | if (MINI) { 304 | return current.length > 2 ? current.slice(1) : current[1]; 305 | } 306 | return current; 307 | }; 308 | 309 | const CACHES = new Map(); 310 | 311 | const regular = function(statics) { 312 | let tmp = CACHES.get(this); 313 | if (!tmp) { 314 | tmp = new Map(); 315 | CACHES.set(this, tmp); 316 | } 317 | tmp = evaluate(this, tmp.get(statics) || (tmp.set(statics, tmp = build(statics)), tmp), arguments, []); 318 | return tmp.length > 1 ? tmp : tmp[0]; 319 | }; 320 | 321 | const custom = function() { 322 | const result = (MINI ? build : regular).apply(this, arguments); 323 | if (result) { 324 | return Array.isArray(result) 325 | ? this(result) 326 | : typeof result === 'object' 327 | ? result 328 | : this([result]); 329 | } 330 | }; 331 | 332 | const wrapper = function() { 333 | const h = custom.bind(this); 334 | return (this.wrap || h).apply(h, arguments); 335 | }; 336 | 337 | export default wrapper; 338 | -------------------------------------------------------------------------------- /src/hydrate.d.ts: -------------------------------------------------------------------------------- 1 | import { JSXInternal } from './jsx'; 2 | import { ElementChildren, FunctionComponent } from './shared'; 3 | 4 | interface VNode

{ 5 | type: string 6 | _props: object 7 | _children: VNode[] 8 | _isSvg: boolean 9 | } 10 | 11 | export function hydrate(delta: VNode, root?: Node): Node; 12 | 13 | export const dhtml: (strings: TemplateStringsArray, ...values: any[]) => VNode | VNode[]; 14 | export const dsvg: (strings: TemplateStringsArray, ...values: any[]) => VNode | VNode[]; 15 | 16 | export function d( 17 | type: string, 18 | props: 19 | | JSXInternal.HTMLAttributes & 20 | Record 21 | | null, 22 | ...children: ElementChildren[] 23 | ): VNode | VNode[]; 24 | export function d( 25 | type: FunctionComponent, 26 | props: 27 | | JSXInternal.HTMLAttributes & 28 | Record 29 | | null, 30 | ...children: ElementChildren[] 31 | ): VNode | VNode[]; 32 | export function d( 33 | children: ElementChildren[] 34 | ): VNode | VNode[]; 35 | export namespace d { 36 | export import JSX = JSXInternal; 37 | } 38 | 39 | export function ds( 40 | type: string, 41 | props: 42 | | JSXInternal.SVGAttributes & 43 | Record 44 | | null, 45 | ...children: ElementChildren[] 46 | ): VNode | VNode[]; 47 | export function ds( 48 | type: FunctionComponent, 49 | props: 50 | | JSXInternal.SVGAttributes & 51 | Record 52 | | null, 53 | ...children: ElementChildren[] 54 | ): VNode | VNode[]; 55 | export function ds( 56 | children: ElementChildren[] 57 | ): VNode | VNode[]; 58 | export namespace ds { 59 | export import JSX = JSXInternal; 60 | } 61 | -------------------------------------------------------------------------------- /src/hydrate.js: -------------------------------------------------------------------------------- 1 | import { h, hs, api } from './index.js'; 2 | import htm from './htm.js'; 3 | 4 | export const d = context(); 5 | export const ds = context(true); 6 | 7 | // `export const html = htm.bind(h)` is not tree-shakeable! 8 | export function dhtml() { 9 | return htm.apply(d, arguments); 10 | } 11 | 12 | // `export const svg = htm.bind(hs)` is not tree-shakeable! 13 | export function dsvg() { 14 | return htm.apply(ds, arguments); 15 | } 16 | 17 | export const _ = {}; 18 | 19 | let isHydrated; 20 | 21 | /** 22 | * Create a sinuous `treeify` function. 23 | * @param {boolean} isSvg 24 | * @return {Function} 25 | */ 26 | export function context(isSvg) { 27 | return function () { 28 | if (isHydrated) { 29 | // Hydrate on first pass, create on the rest. 30 | return (isSvg ? hs : h).apply(null, arguments); 31 | } 32 | 33 | let vnode; 34 | 35 | function item(arg) { 36 | if (arg == null); 37 | else if (arg === _ || typeof arg === 'function') { 38 | // Components can only be the first argument. 39 | if (vnode) { 40 | addChild(vnode, arg); 41 | } else { 42 | vnode = { type: arg, _children: [] }; 43 | } 44 | } else if (Array.isArray(arg)) { 45 | vnode = vnode || { _children: [] }; 46 | arg.forEach(item); 47 | } else if (typeof arg === 'object') { 48 | if (arg._children) { 49 | addChild(vnode, arg); 50 | } else { 51 | vnode._props = arg; 52 | } 53 | } else { 54 | // The rest is made into a string. 55 | if (vnode) { 56 | addChild(vnode, { type: null, _props: arg }); 57 | } else { 58 | vnode = { type: arg, _children: [] }; 59 | } 60 | } 61 | 62 | if (isSvg) vnode._isSvg = isSvg; 63 | } 64 | 65 | function addChild(parent, child) { 66 | parent._children.push(child); 67 | child._parent = parent; 68 | } 69 | 70 | Array.from(arguments).forEach(item); 71 | 72 | return vnode; 73 | }; 74 | } 75 | 76 | /** 77 | * Hydrates the root node with a passed delta tree structure. 78 | * 79 | * `delta` looks like: 80 | * { 81 | * type: 'div', 82 | * _props: { class: '' }, 83 | * _children: [] 84 | * } 85 | * 86 | * @param {object} delta 87 | * @param {Node} [root] 88 | * @return {Node} Returns the `root`. 89 | */ 90 | export function hydrate(delta, root) { 91 | if (!delta) { 92 | return; 93 | } 94 | 95 | if (typeof delta.type === 'function') { 96 | // Support Components 97 | delta = delta.type.apply( 98 | null, 99 | [delta._props].concat(delta._children.map((c) => c())) 100 | ); 101 | } 102 | 103 | let isFragment = delta.type === undefined; 104 | let isRootFragment; 105 | let el; 106 | 107 | if (!root) { 108 | root = document.querySelector(findRootSelector(delta)); 109 | } 110 | 111 | function findRootSelector(delta) { 112 | let selector = ''; 113 | let prop; 114 | if (delta._props && (prop = delta._props.id)) { 115 | selector = '#'; 116 | } else if (delta._props && (prop = delta._props.class)) { 117 | selector = '.'; 118 | } else if ((prop = delta.type)) { 119 | // delta.type is truthy 120 | } else { 121 | isRootFragment = true; 122 | return delta._children && findRootSelector(delta._children[0]()); 123 | } 124 | 125 | return ( 126 | selector + 127 | (typeof prop === 'function' ? prop() : prop) 128 | .split(' ') 129 | // Escape CSS selector https://bit.ly/36h9I83 130 | .map((sel) => sel.replace(/([^\x80-\uFFFF\w-])/g, '\\$1')) 131 | .join('.') 132 | ); 133 | } 134 | 135 | function item(arg) { 136 | if (arg instanceof Node) { 137 | el = arg; 138 | // Keep a child pointer for multiple hydrate calls per element. 139 | el._index = el._index || 0; 140 | } else if (Array.isArray(arg)) { 141 | arg.forEach(item); 142 | } else if (el) { 143 | let target = filterChildNodes(el)[el._index]; 144 | let current; 145 | let prefix; 146 | 147 | const updateText = (text) => { 148 | el._index++; 149 | 150 | // Leave whitespace alone. 151 | if (target.data.trim() !== text.trim()) { 152 | if (arg._parent._children.length !== filterChildNodes(el).length) { 153 | // If the parent's virtual children length don't match the DOM's, 154 | // it's probably adjacent text nodes stuck together. Split them. 155 | target.splitText(target.data.indexOf(text) + text.length); 156 | if (current) { 157 | // Leave prefix whitespace intact. 158 | prefix = current.match(/^\s*/)[0]; 159 | } 160 | } 161 | // Leave whitespace alone. 162 | if (target.data.trim() !== text.trim()) { 163 | target.data = text; 164 | } 165 | } 166 | }; 167 | 168 | if (target) { 169 | // Skip placeholder underscore. 170 | if (arg === _) { 171 | el._index++; 172 | } else if (typeof arg === 'object') { 173 | if (arg.type === null && target.nodeType === 3) { 174 | // This is a text vnode, add noskip so spaces don't get skipped. 175 | target._noskip = true; 176 | updateText(arg._props); 177 | } else if (arg.type) { 178 | hydrate(arg, target); 179 | el._index++; 180 | } 181 | } 182 | } 183 | 184 | if (typeof arg === 'function') { 185 | current = target ? target.data : undefined; 186 | prefix = ''; 187 | let hydrated; 188 | let marker; 189 | let startNode; 190 | api.subscribe(() => { 191 | isHydrated = hydrated; 192 | 193 | let result = arg(); 194 | if (result && result._children) { 195 | result = result.type ? result : result._children; 196 | } 197 | 198 | const isStringable = 199 | typeof result === 'string' || typeof result === 'number'; 200 | result = isStringable ? prefix + result : result; 201 | 202 | if (hydrated || (!target && !isFragment)) { 203 | current = api.insert(el, result, marker, current, startNode); 204 | } else { 205 | if (isStringable) { 206 | updateText(result); 207 | } else { 208 | if (Array.isArray(result)) { 209 | startNode = target; 210 | target = el; 211 | } 212 | 213 | if (isRootFragment) { 214 | target = el; 215 | } 216 | 217 | hydrate(result, target); 218 | current = []; 219 | } 220 | 221 | if (!isRootFragment && target) { 222 | marker = api.add(el, '', filterChildNodes(el)[el._index]); 223 | } else { 224 | marker = api.add(el.parentNode, '', el.nextSibling); 225 | } 226 | } 227 | 228 | isHydrated = false; 229 | hydrated = true; 230 | }); 231 | } else if (typeof arg === 'object') { 232 | if (!arg._children) { 233 | api.property(el, arg, null, delta._isSvg); 234 | } 235 | } 236 | } 237 | } 238 | 239 | [root, delta._props, delta._children || delta].forEach(item); 240 | 241 | return el; 242 | } 243 | 244 | /** 245 | * Filter out whitespace text nodes unless it has a noskip indicator. 246 | * 247 | * @param {Node} parent 248 | * @return {Array} 249 | */ 250 | function filterChildNodes(parent) { 251 | return Array.from(parent.childNodes).filter( 252 | (el) => el.nodeType !== 3 || el.data.trim() || el._noskip 253 | ); 254 | } 255 | -------------------------------------------------------------------------------- /src/hydrate.md: -------------------------------------------------------------------------------- 1 | # Sinuous Hydrate 2 | 3 | Sinuous Hydrate is a small add-on for [Sinuous](https://github.com/luwes/sinuous) that provides fast hydration of static HTML. The HTML or SVG that is defined with this API doesn't have to be exactly the same as the HTML coming from the server. It's perfectly valid to only define the attributes that have any dynamic values in it. This is intentionally done to minimize duplication. 4 | 5 | # Example 6 | 7 | ```js 8 | import { observable } from 'sinuous'; 9 | import { hydrate, d } from 'sinuous/hydrate'; 10 | import { openLogin } from './auth.js'; 11 | 12 | const isActive = observable(''); 13 | 14 | hydrate( 15 | dhtml` 16 | isActive(!isActive() ? ' is-active' : '')} 25 | /> 26 | ` 27 | ); 28 | 29 | hydrate( 30 | dhtml` 31 | 32 | ` 33 | ); 34 | ``` 35 | 36 | # API 37 | 38 | ### hydrate(tree, [root]) ⇒ Node 39 | 40 | Hydrates the root node with the dynamic HTML. 41 | Passing the root node is not needed if it can be derived from the `id` or `class` attribute of the root HTML or SVG tree. 42 | 43 | **Returns**: Node - Returns the root node. 44 | 45 | | Param | Type | Description | 46 | | ------ | ------------------- | ----------------------- | 47 | | tree | Object | Virtual tree structure. | 48 | | [root] | Node | Root node. | 49 | 50 | ### dhtml`` or d() 51 | 52 | Creates a virtual tree structure for HTML. 53 | Looks like: 54 | 55 | ```js 56 | { 57 | type: 'div', 58 | _props: { class: '' }, 59 | _children: [] 60 | } 61 | ``` 62 | 63 | ### dsvg`` or ds() 64 | 65 | Creates a virtual tree structure for SVG. 66 | 67 | ### \_ 68 | 69 | A placeholder for content in tags that get skipped. The placeholder prevents duplication of long static texts in JavaScript which would add unnecessary bytes to your bundle. 70 | 71 | For example: 72 | 73 | ```js 74 | import { hydrate, dhtml, _ } from 'sinuous/hydrate'; 75 | 76 | document.body.innerHTML = ` 77 |

92 | `; 93 | 94 | hydrate(dhtml` 95 |
96 |

${_}

97 |
98 |

${_}

99 | 100 |
101 |
102 | `); 103 | ``` 104 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | export = sinuous; 2 | export as namespace sinuous; 3 | 4 | import { JSXInternal } from './jsx'; 5 | import { HyperscriptApi } from './h'; 6 | import * as _shared from './shared' 7 | import * as _o from './observable'; 8 | 9 | import FunctionComponent = _shared.FunctionComponent; 10 | import ElementChildren = _shared.ElementChildren; 11 | 12 | declare module 'sinuous/jsx' { 13 | namespace JSXInternal { 14 | interface DOMAttributes { 15 | children?: ElementChildren; 16 | } 17 | } 18 | } 19 | 20 | // Adapted from Preact's index.d.ts 21 | // Namespace prevents conflict with React typings 22 | declare namespace sinuous { 23 | export import JSX = JSXInternal; 24 | 25 | export import observable = _o.observable; 26 | export import o = _o.o; 27 | 28 | const html: (strings: TemplateStringsArray, ...values: unknown[]) => HTMLElement | DocumentFragment; 29 | const svg: (strings: TemplateStringsArray, ...values: unknown[]) => SVGElement | DocumentFragment; 30 | 31 | // Split HyperscriptApi's h() tag into functions with more narrow typings 32 | function h( 33 | type: string, 34 | props: 35 | | JSXInternal.HTMLAttributes & 36 | Record 37 | | null, 38 | ...children: ElementChildren[] 39 | ): HTMLElement; 40 | function h( 41 | type: FunctionComponent, 42 | props: 43 | | JSXInternal.HTMLAttributes & 44 | Record 45 | | null, 46 | ...children: ElementChildren[] 47 | ): HTMLElement | DocumentFragment; 48 | function h( 49 | tag: ElementChildren[] | [], 50 | ...children: ElementChildren[] 51 | ): DocumentFragment; 52 | namespace h { 53 | export import JSX = JSXInternal; 54 | } 55 | 56 | function hs( 57 | type: string, 58 | props: 59 | | JSXInternal.SVGAttributes & 60 | Record 61 | | null, 62 | ...children: ElementChildren[] 63 | ): SVGElement; 64 | function hs( 65 | type: FunctionComponent, 66 | props: 67 | | JSXInternal.SVGAttributes & 68 | Record 69 | | null, 70 | ...children: ElementChildren[] 71 | ): SVGElement | DocumentFragment; 72 | function hs( 73 | tag: ElementChildren[] | [], 74 | ...children: ElementChildren[] 75 | ): DocumentFragment; 76 | namespace hs { 77 | export import JSX = JSXInternal; 78 | } 79 | 80 | /** Sinuous API */ 81 | interface SinuousApi extends HyperscriptApi { 82 | // Hyperscript 83 | hs: unknown>(closure: T) => ReturnType; 84 | 85 | // Observable 86 | subscribe: typeof _o.subscribe; 87 | cleanup: typeof _o.cleanup; 88 | root: typeof _o.root; 89 | sample: typeof _o.sample; 90 | } 91 | 92 | const api: SinuousApi; 93 | 94 | } 95 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Sinuous by Wesley Luyten (@luwes). 3 | * Really ties all the packages together. 4 | */ 5 | import { 6 | o, 7 | observable, 8 | computed, 9 | subscribe, 10 | cleanup, 11 | root, 12 | sample, 13 | } from './observable.js'; 14 | import { api } from './h.js'; 15 | import htm from './htm.js'; 16 | 17 | // Minified this is actually smaller than Object.assign(api, { ... }) 18 | api.subscribe = subscribe; 19 | api.cleanup = cleanup; 20 | api.root = root; 21 | api.sample = sample; 22 | 23 | api.hs = (...args) => { 24 | const prevIsSvg = api.s; 25 | api.s = true; 26 | const el = h(...args); 27 | api.s = prevIsSvg; 28 | return el; 29 | }; 30 | 31 | // Makes it possible to intercept `h` calls and customize. 32 | export const h = (...args) => api.h.apply(api.h, args); 33 | 34 | // Makes it possible to intercept `hs` calls and customize. 35 | export const hs = (...args) => api.hs.apply(api.hs, args); 36 | 37 | // `export const html = htm.bind(h)` is not tree-shakeable! 38 | export const html = (...args) => htm.apply(h, args); 39 | 40 | // `export const svg = htm.bind(hs)` is not tree-shakeable! 41 | export const svg = (...args) => htm.apply(hs, args); 42 | 43 | export { api, o, observable, computed }; 44 | -------------------------------------------------------------------------------- /src/map.d.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from './observable'; 2 | 3 | export function map( 4 | items: ((...args: unknown[]) => T[]) | Observable, 5 | expr: (item: T, i: number, items: T[]) => Node, 6 | cleaning?: boolean 7 | ): DocumentFragment; 8 | -------------------------------------------------------------------------------- /src/map.js: -------------------------------------------------------------------------------- 1 | /* Adapted from Stage0 - The MIT License - Pavel Martynov */ 2 | /* Adapted from DOM Expressions - The MIT License - Ryan Carniato */ 3 | import { api } from './index.js'; 4 | 5 | export const GROUPING = '__g'; 6 | export const FORWARD = 'nextSibling'; 7 | export const BACKWARD = 'previousSibling'; 8 | 9 | /** 10 | * Map over a list of items that create DOM nodes. 11 | * 12 | * @param {Function} items - Function or observable that creates a list. 13 | * @param {Function} expr 14 | * @param {boolean} [cleaning] 15 | * @return {DocumentFragment} 16 | */ 17 | export function map(items, expr, cleaning) { 18 | const { subscribe, root, sample, cleanup } = api; 19 | 20 | // Disable cleaning for templates by default. 21 | if (cleaning == null) cleaning = !expr.$t; 22 | 23 | let parent = document.createDocumentFragment(); 24 | const beforeNode = add(parent, ''); 25 | const afterNode = add(parent, ''); 26 | const disposers = new Map(); 27 | 28 | const unsubscribe = subscribe((a) => { 29 | const b = items(); 30 | return sample(() => 31 | reconcile( 32 | a || [], 33 | b || [], 34 | beforeNode, 35 | afterNode, 36 | createFn, 37 | cleaning && disposeAll, 38 | cleaning && dispose 39 | ) 40 | ); 41 | }); 42 | 43 | cleanup(unsubscribe); 44 | cleanup(disposeAll); 45 | 46 | function disposeAll() { 47 | disposers.forEach((d) => d()); 48 | disposers.clear(); 49 | } 50 | 51 | function dispose(node) { 52 | let disposer = disposers.get(node); 53 | disposer && disposer(); 54 | disposers.delete(node); 55 | } 56 | 57 | function createFn(parent, item, i, data, afterNode) { 58 | // The root call makes it possible the child's computations outlive 59 | // their parents' update cycle. 60 | return cleaning 61 | ? root((disposeFn) => { 62 | const node = add(parent, expr(item, i, data), afterNode); 63 | disposers.set(node, disposeFn); 64 | return node; 65 | }) 66 | : add(parent, expr(item, i, data), afterNode); 67 | } 68 | 69 | return parent; 70 | } 71 | 72 | // This is almost straightforward implementation of reconcillation algorithm 73 | // based on ivi documentation: 74 | // https://github.com/localvoid/ivi/blob/2c81ead934b9128e092cc2a5ef2d3cabc73cb5dd/packages/ivi/src/vdom/implementation.ts#L1366 75 | // With some fast paths from Surplus implementation: 76 | // https://github.com/adamhaile/surplus/blob/master/src/runtime/content.ts#L86 77 | // And working with data directly from Stage0: 78 | // https://github.com/Freak613/stage0/blob/master/reconcile.js 79 | // This implementation is tailored for fine grained change detection and adds support for fragments 80 | export function reconcile( 81 | a, 82 | b, 83 | beforeNode, 84 | afterNode, 85 | createFn, 86 | onClear, 87 | onRemove 88 | ) { 89 | // When parent was a DocumentFragment, then items got appended to the DOM. 90 | const parent = afterNode.parentNode; 91 | 92 | let length = b.length; 93 | let i; 94 | 95 | // Fast path for clear 96 | if (length === 0) { 97 | let startMark = beforeNode.previousSibling; 98 | if ((startMark && startMark.previousSibling) || afterNode.nextSibling) { 99 | removeNodes(parent, beforeNode.nextSibling, afterNode); 100 | } else { 101 | parent.textContent = ''; 102 | if (startMark) { 103 | parent.appendChild(startMark); 104 | } 105 | parent.appendChild(beforeNode); 106 | parent.appendChild(afterNode); 107 | } 108 | 109 | onClear && onClear(); 110 | return []; 111 | } 112 | 113 | // Fast path for create 114 | if (a.length === 0) { 115 | for (i = 0; i < length; i++) { 116 | createFn(parent, b[i], i, b, afterNode); 117 | } 118 | return b.slice(); 119 | } 120 | 121 | let aStart = 0; 122 | let bStart = 0; 123 | let aEnd = a.length - 1; 124 | let bEnd = length - 1; 125 | let tmp; 126 | let aStartNode = beforeNode.nextSibling; 127 | let aEndNode = afterNode.previousSibling; 128 | let bAfterNode = afterNode; 129 | let mark; 130 | 131 | fixes: while (true) { 132 | // Skip prefix 133 | while (a[aStart] === b[bStart]) { 134 | bStart++; 135 | aStartNode = step(aStartNode, FORWARD); 136 | if (aEnd < ++aStart || bEnd < bStart) break fixes; 137 | } 138 | 139 | // Skip suffix 140 | while (a[aEnd] === b[bEnd]) { 141 | bEnd--; 142 | bAfterNode = step(aEndNode, BACKWARD, true); 143 | aEndNode = bAfterNode.previousSibling; 144 | if (--aEnd < aStart || bEnd < bStart) break fixes; 145 | } 146 | 147 | break; 148 | } 149 | 150 | // Fast path for shrink 151 | if (bEnd < bStart) { 152 | while (aStart <= aEnd--) { 153 | tmp = step(aEndNode, BACKWARD, true); 154 | mark = tmp.previousSibling; 155 | removeNodes(parent, tmp, aEndNode.nextSibling); 156 | onRemove && onRemove(tmp); 157 | aEndNode = mark; 158 | } 159 | return b.slice(); 160 | } 161 | 162 | // Fast path for add 163 | if (aEnd < aStart) { 164 | while (bStart <= bEnd) { 165 | createFn(parent, b[bStart++], bStart, b, bAfterNode); 166 | } 167 | return b.slice(); 168 | } 169 | 170 | // Positions for reusing nodes from current DOM state 171 | const P = new Array(bEnd + 1 - bStart); 172 | // Index to resolve position from current to new 173 | const I = new Map(); 174 | for (i = bStart; i <= bEnd; i++) { 175 | P[i] = -1; 176 | I.set(b[i], i); 177 | } 178 | 179 | // Re-using `length` variable for reusing nodes count. 180 | length = 0; 181 | let toRemove = []; 182 | for (i = aStart; i <= aEnd; i++) { 183 | tmp = I.get(a[i]); 184 | if (tmp) { 185 | P[tmp] = i; 186 | length++; 187 | } else { 188 | toRemove.push(i); 189 | } 190 | } 191 | 192 | // Fast path for full replace 193 | if (length === 0) { 194 | return reconcile( 195 | reconcile(a, [], beforeNode, afterNode, createFn, onClear), 196 | b, 197 | beforeNode, 198 | afterNode, 199 | createFn 200 | ); 201 | } 202 | 203 | // Collect nodes to work with them 204 | const nodes = []; 205 | tmp = aStartNode; 206 | for (i = aStart; i <= aEnd; i++) { 207 | nodes[i] = tmp; 208 | tmp = step(tmp, FORWARD); 209 | } 210 | 211 | for (i = 0; i < toRemove.length; i++) { 212 | let index = toRemove[i]; 213 | tmp = nodes[index]; 214 | removeNodes(parent, tmp, step(tmp, FORWARD)); 215 | onRemove && onRemove(tmp); 216 | } 217 | 218 | const longestSeq = longestPositiveIncreasingSubsequence(P, bStart); 219 | // Re-use `length` for longest sequence length. 220 | length = longestSeq.length - 1; 221 | 222 | for (i = bEnd; i >= bStart; i--) { 223 | if (longestSeq[length] === i) { 224 | bAfterNode = nodes[P[longestSeq[length]]]; 225 | length--; 226 | } else { 227 | if (P[i] === -1) { 228 | tmp = createFn(parent, b[i], i, b, bAfterNode); 229 | } else { 230 | tmp = nodes[P[i]]; 231 | insertNodes(parent, tmp, step(tmp, FORWARD), bAfterNode); 232 | } 233 | bAfterNode = tmp; 234 | } 235 | } 236 | 237 | return b.slice(); 238 | } 239 | 240 | let groupCounter = 0; 241 | 242 | function add(parent, value, endMark) { 243 | let mark; 244 | 245 | if (typeof value === 'string') { 246 | value = document.createTextNode(value); 247 | } else if (!(value instanceof Node)) { 248 | // Passing an empty array creates a DocumentFragment. 249 | value = api.h([], value); 250 | } 251 | 252 | if ( 253 | value.nodeType === 11 && 254 | (mark = value.firstChild) && 255 | mark !== value.lastChild 256 | ) { 257 | mark[GROUPING] = value.lastChild[GROUPING] = ++groupCounter; 258 | } 259 | 260 | // If endMark is `null`, value will be added to the end of the list. 261 | parent.insertBefore(value, endMark); 262 | 263 | // Explicit undefined to store if frag.firstChild is null. 264 | return mark || value; 265 | } 266 | 267 | function step(node, direction, inner) { 268 | const key = node[GROUPING]; 269 | if (key) { 270 | node = node[direction]; 271 | while (node && node[GROUPING] !== key) { 272 | node = node[direction]; 273 | } 274 | } 275 | return inner ? node : node[direction]; 276 | } 277 | 278 | function removeNodes(parent, node, end) { 279 | let tmp; 280 | while (node !== end) { 281 | tmp = node.nextSibling; 282 | parent.removeChild(node); 283 | node = tmp; 284 | } 285 | } 286 | 287 | function insertNodes(parent, node, end, target) { 288 | let tmp; 289 | while (node !== end) { 290 | tmp = node.nextSibling; 291 | parent.insertBefore(node, target); 292 | node = tmp; 293 | } 294 | } 295 | 296 | // Picked from 297 | // https://github.com/adamhaile/surplus/blob/master/src/runtime/content.ts#L368 298 | 299 | // return an array of the indices of ns that comprise the longest increasing subsequence within ns 300 | function longestPositiveIncreasingSubsequence(ns, newStart) { 301 | let seq = []; 302 | let is = []; 303 | let l = -1; 304 | let pre = new Array(ns.length); 305 | 306 | for (var i = newStart, len = ns.length; i < len; i++) { 307 | var n = ns[i]; 308 | if (n < 0) continue; 309 | var j = findGreatestIndexLEQ(seq, n); 310 | if (j !== -1) pre[i] = is[j]; 311 | if (j === l) { 312 | l++; 313 | seq[l] = n; 314 | is[l] = i; 315 | } else if (n < seq[j + 1]) { 316 | seq[j + 1] = n; 317 | is[j + 1] = i; 318 | } 319 | } 320 | 321 | for (i = is[l]; l >= 0; i = pre[i], l--) { 322 | seq[l] = i; 323 | } 324 | 325 | return seq; 326 | } 327 | 328 | function findGreatestIndexLEQ(seq, n) { 329 | // invariant: lo is guaranteed to be index of a value <= n, hi to be > 330 | // therefore, they actually start out of range: (-1, last + 1) 331 | let lo = -1; 332 | let hi = seq.length; 333 | 334 | // fast path for simple increasing sequences 335 | if (hi > 0 && seq[hi - 1] <= n) return hi - 1; 336 | 337 | while (hi - lo > 1) { 338 | var mid = ((lo + hi) / 2) | 0; 339 | if (seq[mid] > n) { 340 | hi = mid; 341 | } else { 342 | lo = mid; 343 | } 344 | } 345 | 346 | return lo; 347 | } 348 | -------------------------------------------------------------------------------- /src/observable.d.ts: -------------------------------------------------------------------------------- 1 | export interface Observable { 2 | (): T; 3 | (nextValue: T): T; 4 | } 5 | export function observable(value: T): Observable; 6 | export function o(value: T): Observable; 7 | export function computed unknown>(observer: T, seed?: unknown): T; 8 | export function S unknown>(observer: T, seed?: unknown): T; 9 | 10 | export function subscribe(observer: () => T): () => void; 11 | export function unsubscribe(observer: () => T): void; 12 | export function isListening(): boolean; 13 | export function root(fn: () => T): T; 14 | export function sample(fn: () => T): T; 15 | export function transaction(fn: () => T): T; 16 | export function on unknown>(observables: Observable[], fn: T, seed?: unknown, onchanges?: boolean): T; 17 | export function cleanup unknown>(fn: T): T; 18 | -------------------------------------------------------------------------------- /src/observable.js: -------------------------------------------------------------------------------- 1 | const EMPTY_ARR = []; 2 | let tracking; 3 | let queue; 4 | 5 | /** 6 | * Returns true if there is an active observer. 7 | * @return {boolean} 8 | */ 9 | export function isListening() { 10 | return !!tracking; 11 | } 12 | 13 | /** 14 | * Creates a root and executes the passed function that can contain computations. 15 | * The executed function receives an `unsubscribe` argument which can be called to 16 | * unsubscribe all inner computations. 17 | * 18 | * @param {Function} fn 19 | * @return {*} 20 | */ 21 | export function root(fn) { 22 | const prevTracking = tracking; 23 | const rootUpdate = () => {}; 24 | tracking = rootUpdate; 25 | resetUpdate(rootUpdate); 26 | const result = fn(() => { 27 | _unsubscribe(rootUpdate); 28 | tracking = undefined; 29 | }); 30 | tracking = prevTracking; 31 | return result; 32 | } 33 | 34 | /** 35 | * Sample the current value of an observable but don't create a dependency on it. 36 | * 37 | * @example 38 | * computed(() => { if (foo()) bar(sample(bar) + 1); }); 39 | * 40 | * @param {Function} fn 41 | * @return {*} 42 | */ 43 | export function sample(fn) { 44 | const prevTracking = tracking; 45 | tracking = undefined; 46 | const value = fn(); 47 | tracking = prevTracking; 48 | return value; 49 | } 50 | 51 | /** 52 | * Creates a transaction in which an observable can be set multiple times 53 | * but only trigger a computation once. 54 | * @param {Function} fn 55 | * @return {*} 56 | */ 57 | export function transaction(fn) { 58 | let prevQueue = queue; 59 | queue = []; 60 | const result = fn(); 61 | let q = queue; 62 | queue = prevQueue; 63 | q.forEach((data) => { 64 | if (data._pending !== EMPTY_ARR) { 65 | const pending = data._pending; 66 | data._pending = EMPTY_ARR; 67 | data(pending); 68 | } 69 | }); 70 | return result; 71 | } 72 | 73 | /** 74 | * Creates a new observable, returns a function which can be used to get 75 | * the observable's value by calling the function without any arguments 76 | * and set the value by passing one argument of any type. 77 | * 78 | * @param {*} value - Initial value. 79 | * @return {Function} 80 | */ 81 | function observable(value) { 82 | function data(nextValue) { 83 | if (arguments.length === 0) { 84 | if (tracking && !data._observers.has(tracking)) { 85 | data._observers.add(tracking); 86 | tracking._observables.push(data); 87 | } 88 | return value; 89 | } 90 | 91 | if (queue) { 92 | if (data._pending === EMPTY_ARR) { 93 | queue.push(data); 94 | } 95 | data._pending = nextValue; 96 | return nextValue; 97 | } 98 | 99 | value = nextValue; 100 | 101 | // Clear `tracking` otherwise a computed triggered by a set 102 | // in another computed is seen as a child of that other computed. 103 | const clearedUpdate = tracking; 104 | tracking = undefined; 105 | 106 | // Update can alter data._observers, make a copy before running. 107 | data._runObservers = new Set(data._observers); 108 | data._runObservers.forEach((observer) => (observer._fresh = false)); 109 | data._runObservers.forEach((observer) => { 110 | if (!observer._fresh) observer(); 111 | }); 112 | 113 | tracking = clearedUpdate; 114 | return value; 115 | } 116 | 117 | // Tiny indicator that this is an observable function. 118 | // Used in sinuous/h/src/property.js 119 | data.$o = 1; 120 | data._observers = new Set(); 121 | // The 'not set' value must be unique, so `nullish` can be set in a transaction. 122 | data._pending = EMPTY_ARR; 123 | 124 | return data; 125 | } 126 | 127 | /** 128 | * @namespace 129 | * @borrows observable as o 130 | */ 131 | export { observable, observable as o }; 132 | 133 | /** 134 | * Creates a new computation which runs when defined and automatically re-runs 135 | * when any of the used observable's values are set. 136 | * 137 | * @param {Function} observer 138 | * @param {*} value - Seed value. 139 | * @return {Function} Computation which can be used in other computations. 140 | */ 141 | function computed(observer, value) { 142 | observer._update = update; 143 | 144 | // if (tracking == null) { 145 | // console.warn("computations created without a root or parent will never be disposed"); 146 | // } 147 | 148 | resetUpdate(update); 149 | update(); 150 | 151 | function update() { 152 | const prevTracking = tracking; 153 | if (tracking) { 154 | tracking._children.push(update); 155 | } 156 | 157 | _unsubscribe(update); 158 | update._fresh = true; 159 | tracking = update; 160 | value = observer(value); 161 | 162 | tracking = prevTracking; 163 | return value; 164 | } 165 | 166 | // Tiny indicator that this is an observable function. 167 | // Used in sinuous/h/src/property.js 168 | data.$o = 1; 169 | 170 | function data() { 171 | if (update._fresh) { 172 | if (tracking) { 173 | // If being read from inside another computed, pass observables to it 174 | update._observables.forEach((o) => o()); 175 | } 176 | } else { 177 | value = update(); 178 | } 179 | return value; 180 | } 181 | 182 | return data; 183 | } 184 | 185 | /** 186 | * @namespace 187 | * @borrows computed as S 188 | */ 189 | export { computed, computed as S }; 190 | 191 | /** 192 | * Run the given function just before the enclosing computation updates 193 | * or is disposed. 194 | * @param {Function} fn 195 | * @return {Function} 196 | */ 197 | export function cleanup(fn) { 198 | if (tracking) { 199 | tracking._cleanups.push(fn); 200 | } 201 | return fn; 202 | } 203 | 204 | /** 205 | * Subscribe to updates of an observable. 206 | * @param {Function} observer 207 | * @return {Function} 208 | */ 209 | export function subscribe(observer) { 210 | computed(observer); 211 | return () => _unsubscribe(observer._update); 212 | } 213 | 214 | /** 215 | * Statically declare a computation's dependencies. 216 | * 217 | * @param {Function|Array} obs 218 | * @param {Function} fn - Callback function. 219 | * @param {*} [seed] - Seed value. 220 | * @param {boolean} [onchanges] - If true the initial run is skipped. 221 | * @return {Function} Computation which can be used in other computations. 222 | */ 223 | export function on(obs, fn, seed, onchanges) { 224 | obs = [].concat(obs); 225 | return computed((value) => { 226 | obs.forEach((o) => o()); 227 | 228 | let result = value; 229 | if (!onchanges) { 230 | result = sample(() => fn(value)); 231 | } 232 | 233 | onchanges = false; 234 | return result; 235 | }, seed); 236 | } 237 | 238 | /** 239 | * Unsubscribe from an observer. 240 | * @param {Function} observer 241 | */ 242 | export function unsubscribe(observer) { 243 | _unsubscribe(observer._update); 244 | } 245 | 246 | function _unsubscribe(update) { 247 | update._children.forEach(_unsubscribe); 248 | update._observables.forEach((o) => { 249 | o._observers.delete(update); 250 | if (o._runObservers) { 251 | o._runObservers.delete(update); 252 | } 253 | }); 254 | update._cleanups.forEach((c) => c()); 255 | resetUpdate(update); 256 | } 257 | 258 | function resetUpdate(update) { 259 | // Keep track of which observables trigger updates. Needed for unsubscribe. 260 | update._observables = []; 261 | update._children = []; 262 | update._cleanups = []; 263 | } 264 | -------------------------------------------------------------------------------- /src/observable.md: -------------------------------------------------------------------------------- 1 | # Sinuous Observable 2 | 3 | Sinuous Observable is a tiny reactive library. It shares the core features of [S.js](https://github.com/adamhaile/S) to be the engine driving the reactive dom operations in [Sinuous](https://github.com/luwes/sinuous). 4 | 5 | ## Features 6 | 7 | - Automatic updates: when an observable changes, any computation that read the old value will re-run. 8 | - Automatic disposals: any child computations are automatically disposed when the parent is re-run. 9 | 10 | # API 11 | 12 | ### Functions 13 | 14 | - [isListening()](#isListening) ⇒ boolean 15 | - [root(fn)](#root) ⇒ \* 16 | - [sample(fn)](#sample) ⇒ \* 17 | - [transaction(fn)](#transaction) ⇒ \* 18 | - [observable(value)](#observable) ⇒ function 19 | - [computed(observer, value)](#computed) ⇒ function 20 | - [cleanup(fn)](#cleanup) ⇒ function 21 | - [subscribe(observer)](#subscribe) ⇒ function 22 | - [on(obs, fn, [seed], [onchanges])](#on) ⇒ function 23 | - [unsubscribe(observer)](#unsubscribe) 24 | 25 |
26 | 27 | ### isListening() ⇒ boolean 28 | 29 | Returns true if there is an active observer. 30 | 31 | **Kind**: global function 32 | 33 | --- 34 | 35 | 36 | 37 | ### root(fn) ⇒ \* 38 | 39 | Creates a root and executes the passed function that can contain computations. 40 | The executed function receives an `unsubscribe` argument which can be called to 41 | unsubscribe all inner computations. 42 | 43 | **Kind**: global function 44 | 45 | | Param | Type | 46 | | ----- | --------------------- | 47 | | fn | function | 48 | 49 | --- 50 | 51 | 52 | 53 | ### sample(fn) ⇒ \* 54 | 55 | Sample the current value of an observable but don't create a dependency on it. 56 | 57 | **Kind**: global function 58 | 59 | | Param | Type | 60 | | ----- | --------------------- | 61 | | fn | function | 62 | 63 | **Example** 64 | 65 | ```js 66 | computed(() => { 67 | if (foo()) bar(sample(bar) + 1); 68 | }); 69 | ``` 70 | 71 | --- 72 | 73 | 74 | 75 | ### transaction(fn) ⇒ \* 76 | 77 | Creates a transaction in which an observable can be set multiple times 78 | but only trigger a computation once. 79 | 80 | **Kind**: global function 81 | 82 | | Param | Type | 83 | | ----- | --------------------- | 84 | | fn | function | 85 | 86 | --- 87 | 88 | 89 | 90 | ### observable(value) ⇒ function 91 | 92 | Creates a new observable, returns a function which can be used to get 93 | the observable's value by calling the function without any arguments 94 | and set the value by passing one argument of any type. 95 | 96 | **Kind**: global function 97 | 98 | | Param | Type | Description | 99 | | ----- | --------------- | -------------- | 100 | | value | \* | Initial value. | 101 | 102 | --- 103 | 104 | 105 | 106 | ### computed(observer, value) ⇒ function 107 | 108 | Creates a new computation which runs when defined and automatically re-runs 109 | when any of the used observable's values are set. 110 | 111 | **Kind**: global function 112 | **Returns**: function - Computation which can be used in other computations. 113 | 114 | | Param | Type | Description | 115 | | -------- | --------------------- | ----------- | 116 | | observer | function | | 117 | | value | \* | Seed value. | 118 | 119 | --- 120 | 121 | 122 | 123 | ### cleanup(fn) ⇒ function 124 | 125 | Run the given function just before the enclosing computation updates 126 | or is disposed. 127 | 128 | **Kind**: global function 129 | 130 | | Param | Type | 131 | | ----- | --------------------- | 132 | | fn | function | 133 | 134 | --- 135 | 136 | 137 | 138 | ### subscribe(observer) ⇒ function 139 | 140 | Subscribe to updates of an observable. 141 | 142 | **Kind**: global function 143 | 144 | | Param | Type | 145 | | -------- | --------------------- | 146 | | observer | function | 147 | 148 | --- 149 | 150 | 151 | 152 | ### on(obs, fn, [seed], [onchanges]) ⇒ function 153 | 154 | Statically declare a computation's dependencies. 155 | 156 | **Kind**: global function 157 | **Returns**: function - Computation which can be used in other computations. 158 | 159 | | Param | Type | Description | 160 | | ----------- | ------------------------------------------- | ----------------------------------- | 161 | | obs | function \| Array | | 162 | | fn | function | Callback function. | 163 | | [seed] | \* | Seed value. | 164 | | [onchanges] | boolean | If true the initial run is skipped. | 165 | 166 | --- 167 | 168 | 169 | 170 | ### unsubscribe(observer) 171 | 172 | Unsubscribe from an observer. 173 | 174 | **Kind**: global function 175 | 176 | | Param | Type | 177 | | -------- | --------------------- | 178 | | observer | function | 179 | 180 | --- 181 | 182 | # Example 183 | 184 | ```js 185 | import observable, { S } from 'sinuous/observable'; 186 | 187 | var order = '', 188 | a = observable(0), 189 | b = S(function () { 190 | order += 'b'; 191 | return a() + 1; 192 | }), 193 | c = S(function () { 194 | order += 'c'; 195 | return b() || d(); 196 | }), 197 | d = S(function () { 198 | order += 'd'; 199 | return a() + 10; 200 | }); 201 | 202 | console.log(order); // bcd 203 | 204 | order = ''; 205 | a(-1); 206 | 207 | console.log(order); // bcd 208 | console.log(c()); // 9 209 | 210 | order = ''; 211 | a(0); 212 | 213 | console.log(order); // bcd 214 | console.log(c()); // 1 215 | ``` 216 | -------------------------------------------------------------------------------- /src/shared.d.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from './observable'; 2 | 3 | export type ElementChild = 4 | | Node 5 | | Function 6 | | Observable 7 | | object 8 | | string 9 | | number 10 | | boolean 11 | | null 12 | | undefined; 13 | type ElementChildren = ElementChild[] | ElementChild; 14 | 15 | export interface FunctionComponent { 16 | (props: object, ...children: ElementChildren[]): Node 17 | (...children: ElementChildren[]): Node 18 | } 19 | -------------------------------------------------------------------------------- /src/template.d.ts: -------------------------------------------------------------------------------- 1 | export function t(key: string): () => string; 2 | export function o(key: string): () => string; 3 | 4 | interface CloneFunction { 5 | (props: Record): Node; 6 | } 7 | 8 | export function template(elementRef: () => Node): CloneFunction; 9 | 10 | interface FillFunction { 11 | (props: Record): Node; 12 | } 13 | 14 | export function fill(elementRef: () => Node): FillFunction; 15 | -------------------------------------------------------------------------------- /src/template.js: -------------------------------------------------------------------------------- 1 | import { api } from './index.js'; 2 | 3 | let recordedActions; 4 | 5 | /** 6 | * Observed template tag. 7 | * @param {string} key 8 | * @return {Function} 9 | */ 10 | export function o(key) { 11 | return t(key, true); 12 | } 13 | 14 | /** 15 | * Template tag. 16 | * @param {string} key 17 | * @param {boolean} [observed] 18 | * @param {boolean} [bind] 19 | * @return {Function} 20 | */ 21 | export function t(key, observed, bind) { 22 | const tag = function () { 23 | // eslint-disable-next-line 24 | const { el, name, endMark } = this; 25 | 26 | const action = (element, endMark, propName, value) => { 27 | if (propName == null) { 28 | // Store state on the unique endMark per action. 29 | const state = endMark || element; 30 | 31 | // Performance optimization for when the tag is the only content child. 32 | // Default current value to empty string which makes a text insert faster. 33 | if ( 34 | endMark && 35 | endMark._current === undefined && 36 | element.firstChild === element.lastChild && 37 | element.firstChild === endMark 38 | ) { 39 | endMark._current = ''; 40 | } 41 | 42 | state._current = api.insert(element, value, endMark, state._current); 43 | } else { 44 | api.property(element, value, propName); 45 | } 46 | }; 47 | 48 | action._el = el; 49 | action._endMark = endMark; 50 | action._propName = name; 51 | action._key = key; 52 | action._observed = observed; 53 | action._bind = bind; 54 | recordedActions.push(action); 55 | }; 56 | 57 | // Tiny indicator that this is a template tag. 58 | // Used in sinuous/h/src/property.js 59 | tag.$o = 2; 60 | 61 | return tag; 62 | } 63 | 64 | /** 65 | * Creates a template function. 66 | * @param {Function} elementRef 67 | * @param {boolean} noClone 68 | * @return {Function} 69 | */ 70 | export function template(elementRef, noClone) { 71 | const prevRecordedActions = recordedActions; 72 | recordedActions = []; 73 | 74 | const tpl = elementRef(); 75 | 76 | const cloneActions = recordedActions; 77 | recordedActions = prevRecordedActions; 78 | 79 | let fragment = tpl.content || (tpl.parentNode && tpl); 80 | if (!fragment) { 81 | fragment = document.createDocumentFragment(); 82 | fragment.appendChild(tpl); 83 | } 84 | 85 | let stamp = fragment.cloneNode(true); 86 | 87 | if (!noClone) { 88 | cloneActions.forEach((action) => { 89 | action._paths = createPath(fragment, action._el); 90 | action._endMarkPath = 91 | action._endMark && createPath(action._el, action._endMark); 92 | }); 93 | } 94 | 95 | function create(props, forceNoClone) { 96 | // Explicit check for a boolean here, this fn tends to be used in Array.map. 97 | if (forceNoClone === false || forceNoClone === true) noClone = forceNoClone; 98 | 99 | const keyedActions = {}; 100 | let root; 101 | if (noClone) { 102 | if (fragment._childNodes) { 103 | fragment._childNodes.forEach((child) => fragment.appendChild(child)); 104 | } 105 | root = fragment; 106 | } else { 107 | root = stamp.cloneNode(true); 108 | } 109 | 110 | // Set a custom property `props` for easy access to the passed argument. 111 | if (root.firstChild) { 112 | root.firstChild.props = props; 113 | } 114 | 115 | // These paths have to be resolved before any elements are inserted. 116 | cloneActions.forEach((action) => { 117 | action._target = noClone ? action._el : getPath(root, action._paths); 118 | action._endMarkTarget = noClone 119 | ? action._endMark 120 | : action._endMarkPath && getPath(action._target, action._endMarkPath); 121 | }); 122 | 123 | cloneActions.forEach((action) => { 124 | api.action(action, props, keyedActions)(action._key, action._propName); 125 | }); 126 | 127 | // Copy the childNodes after inserting the values. This is needed for 128 | // fills with primitive values that stay the same between renders. 129 | fragment._childNodes = Array.from(fragment.childNodes); 130 | 131 | return root; 132 | } 133 | 134 | // Tiny indicator that this is a template create function. 135 | create.$t = true; 136 | 137 | return create; 138 | } 139 | 140 | api.action = (action, props, keyedActions) => { 141 | const target = action._target; 142 | 143 | // In the `data` module `key` and `propName` are transformed for special cases. 144 | return (key, propName) => { 145 | let value = props[key]; 146 | if (value != null) { 147 | action(target, action._endMarkTarget, propName, value); 148 | } 149 | 150 | if (action._observed) { 151 | if (!keyedActions[key]) { 152 | keyedActions[key] = []; 153 | 154 | Object.defineProperty(props, key, { 155 | get() { 156 | if (action._bind) { 157 | if (propName in target) { 158 | return target[propName]; 159 | } 160 | return target; 161 | } 162 | return value; 163 | }, 164 | set(newValue) { 165 | value = newValue; 166 | keyedActions[key].forEach((action) => action(newValue)); 167 | }, 168 | }); 169 | } 170 | keyedActions[key].push( 171 | action.bind(null, target, action._endMarkTarget, propName) 172 | ); 173 | } 174 | }; 175 | }; 176 | 177 | function createPath(root, el) { 178 | let paths = []; 179 | let parent; 180 | while ((parent = el.parentNode) !== root.parentNode) { 181 | paths.unshift(Array.from(parent.childNodes).indexOf(el)); 182 | el = parent; 183 | } 184 | return paths; 185 | } 186 | 187 | function getPath(target, paths) { 188 | paths.forEach((depth) => (target = target.childNodes[depth])); 189 | return target; 190 | } 191 | -------------------------------------------------------------------------------- /src/template.md: -------------------------------------------------------------------------------- 1 | # Sinuous Template 2 | 3 | A template can look something like this: 4 | 5 | ```js 6 | import { h } from 'sinuous'; 7 | import { template, t, o } from 'sinuous/template'; 8 | 9 | const Row = template( 10 | () => html` 11 | 12 | 13 | ${o('label')} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ` 22 | ); 23 | ``` 24 | 25 | - `o` is an observable tag. 26 | It adds a proxy on the passed object property and repeats the recorded tag action when set. 27 | - `t` is a normal tag. 28 | 29 | The `Row` in this case would accept a object like so 30 | 31 | ```js 32 | Row({ id: 1, label: 'Banana', selected: 'peel' }); 33 | ``` 34 | -------------------------------------------------------------------------------- /test/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | browser: true 4 | 5 | rules: 6 | fp/no-mutating-methods: off 7 | fp/no-loops: off 8 | fp/no-rest-parameters: off 9 | -------------------------------------------------------------------------------- /test/_polyfills.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | /** 4 | * Protect window.console method calls, e.g. console is not defined on IE 5 | * unless dev tools are open, and IE doesn't define console.debug 6 | * 7 | * Chrome 41.0.2272.118: debug,error,info,log,warn,dir,dirxml,table,trace,assert,count,markTimeline,profile,profileEnd,time,timeEnd,timeStamp,timeline,timelineEnd,group,groupCollapsed,groupEnd,clear 8 | * Firefox 37.0.1: log,info,warn,error,exception,debug,table,trace,dir,group,groupCollapsed,groupEnd,time,timeEnd,profile,profileEnd,assert,count 9 | * Internet Explorer 11: select,log,info,warn,error,debug,assert,time,timeEnd,timeStamp,group,groupCollapsed,groupEnd,trace,clear,dir,dirxml,count,countReset,cd 10 | * Safari 6.2.4: debug,error,log,info,warn,clear,dir,dirxml,table,trace,assert,count,profile,profileEnd,time,timeEnd,timeStamp,group,groupCollapsed,groupEnd 11 | * Opera 28.0.1750.48: debug,error,info,log,warn,dir,dirxml,table,trace,assert,count,markTimeline,profile,profileEnd,time,timeEnd,timeStamp,timeline,timelineEnd,group,groupCollapsed,groupEnd,clear 12 | */ 13 | (function() { 14 | // Union of Chrome, Firefox, IE, Opera, and Safari console methods 15 | var methods = [ 16 | 'assert', 17 | 'cd', 18 | 'clear', 19 | 'count', 20 | 'countReset', 21 | 'debug', 22 | 'dir', 23 | 'dirxml', 24 | 'error', 25 | 'exception', 26 | 'group', 27 | 'groupCollapsed', 28 | 'groupEnd', 29 | 'info', 30 | 'log', 31 | 'markTimeline', 32 | 'profile', 33 | 'profileEnd', 34 | 'select', 35 | 'table', 36 | 'time', 37 | 'timeEnd', 38 | 'timeStamp', 39 | 'timeline', 40 | 'timelineEnd', 41 | 'trace', 42 | 'warn' 43 | ]; 44 | var length = methods.length; 45 | var console = (window.console = window.console || {}); 46 | var method; 47 | var noop = function() {}; 48 | while (length--) { 49 | method = methods[length]; 50 | // define undefined methods as noops to prevent errors 51 | if (!console[method]) console[method] = noop; 52 | } 53 | })(); 54 | -------------------------------------------------------------------------------- /test/_utils.js: -------------------------------------------------------------------------------- 1 | export function normalizeSvg(html) { 2 | // IE doesn't support `.outerHTML` of an SVG element. 3 | const htmlStr = 4 | typeof html === 'string' 5 | ? html 6 | : new XMLSerializer().serializeToString(html); 7 | 8 | // Normalization logic from Preact test helpers. 9 | return normalizeAttributes( 10 | htmlStr.replace(' xmlns="http://www.w3.org/2000/svg"', '') 11 | ); 12 | } 13 | 14 | export function normalizeAttributes(htmlStr) { 15 | return htmlStr.replace( 16 | /<([a-z0-9-]+)((?:\s[a-z0-9:_.-]+=".*?")+)((?:\s*\/)?>)/gi, 17 | (s, pre, attrs, after) => { 18 | let list = attrs 19 | .match(/\s[a-z0-9:_.-]+=".*?"/gi) 20 | .sort((a, b) => (a > b ? 1 : -1)); 21 | if (~after.indexOf('/')) after = '>'; 22 | return '<' + pre + list.join('') + after; 23 | } 24 | ); 25 | } 26 | 27 | export function fragInnerHTML(fragment) { 28 | return [].slice 29 | .call(fragment.childNodes) 30 | .map(c => c.outerHTML) 31 | .join(''); 32 | } 33 | 34 | export function beforeEach(test, handler) { 35 | return function tapish(name, listener) { 36 | test(name, function(assert) { 37 | var _end = assert.end; 38 | assert.end = function() { 39 | assert.end = _end; 40 | listener(assert); 41 | }; 42 | 43 | handler(assert); 44 | }); 45 | }; 46 | } 47 | 48 | export function stripExpressionMarkers(value) { 49 | return value; 50 | } 51 | -------------------------------------------------------------------------------- /test/h/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | browser: true 4 | 5 | rules: 6 | fp/no-mutating-methods: off 7 | fp/no-loops: off 8 | -------------------------------------------------------------------------------- /test/h/add-node.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { h } from 'sinuous'; 3 | import { add as addNode } from '../../src/h.js'; 4 | 5 | let counter = 0; 6 | 7 | test('addNode inserts fragment', function(t) { 8 | const parent = document.createElement('div'); 9 | parent.appendChild(document.createTextNode('test')); 10 | 11 | const fragment = document.createDocumentFragment(); 12 | fragment.appendChild(h('h1')); 13 | addNode(parent, fragment); 14 | 15 | t.equal(parent.innerHTML, 'test

'); 16 | t.end(); 17 | }); 18 | 19 | test('addNode inserts fragment w/ marker', function(t) { 20 | const parent = document.createElement('div'); 21 | parent.appendChild(document.createTextNode('test')); 22 | 23 | const marker = parent.appendChild(document.createElement('span')); 24 | const fragment = document.createDocumentFragment(); 25 | fragment.appendChild(h('h1')); 26 | fragment.appendChild(h('h2')); 27 | addNode(parent, fragment, marker, ++counter); 28 | 29 | t.equal(parent.innerHTML, 'test

'); 30 | t.end(); 31 | }); 32 | 33 | test('addNode inserts strings', function(t) { 34 | const parent = document.createElement('div'); 35 | addNode(parent, '⛄'); 36 | t.equal(parent.innerHTML, '⛄'); 37 | t.end(); 38 | }); 39 | 40 | test('addNode inserts numbers', function(t) { 41 | const parent = document.createElement('div'); 42 | addNode(parent, 99); 43 | t.equal(parent.innerHTML, '99'); 44 | t.end(); 45 | }); 46 | 47 | test('addNode inserts nodes', function(t) { 48 | const parent = document.createElement('div'); 49 | const node = document.createElement('div'); 50 | t.equal(addNode(parent, node), node); 51 | t.end(); 52 | }); 53 | -------------------------------------------------------------------------------- /test/h/hyperscript.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import spy from 'ispy'; 3 | import { o, h, hs } from 'sinuous'; 4 | 5 | test('simple', function(t) { 6 | t.equal(h('h1').outerHTML, '

'); 7 | t.equal(h('h1', 'hello world').outerHTML, '

hello world

'); 8 | t.end(); 9 | }); 10 | 11 | test('nested', function(t) { 12 | t.equal( 13 | h('div', h('h1', 'Title'), h('p', 'Paragraph')).outerHTML, 14 | '

Title

Paragraph

' 15 | ); 16 | t.end(); 17 | }); 18 | 19 | test('arrays for nesting is ok', function(t) { 20 | t.equal( 21 | h('div', [h('h1', 'Title'), h('p', 'Paragraph')]).outerHTML, 22 | '

Title

Paragraph

' 23 | ); 24 | t.end(); 25 | }); 26 | 27 | test('can use namespace in name', function(t) { 28 | t.equal(h('myns:mytag').outerHTML, ''); 29 | t.end(); 30 | }); 31 | 32 | // test('can use id selector', function(t) { 33 | // t.equal(h('div#frame').outerHTML, '
'); 34 | // t.end(); 35 | // }); 36 | 37 | // test('can use class selector', function(t) { 38 | // t.equal(h('div.panel').outerHTML, '
'); 39 | // t.end(); 40 | // }); 41 | 42 | // test('can default element types', function(t) { 43 | // t.equal(h('.panel').outerHTML, '
'); 44 | // t.equal(h('#frame').outerHTML, '
'); 45 | // t.end(); 46 | // }); 47 | 48 | test('can set properties', function(t) { 49 | let a = h('a', { href: 'http://google.com' }); 50 | t.equal(a.href, 'http://google.com/'); 51 | let checkbox = h('input', { name: 'yes', type: 'checkbox' }); 52 | t.equal(checkbox.outerHTML, ''); 53 | t.end(); 54 | }); 55 | 56 | test('(un)registers an event handler', function(t) { 57 | // don't try the focus event, valid tests fail in IE11 58 | 59 | let click = spy(); 60 | let btn = h('button', { onclick: click }, 'something'); 61 | document.body.appendChild(btn); 62 | 63 | btn.click(); 64 | t.equal(click.callCount, 1, 'click called'); 65 | 66 | h(btn, { onclick: false }); 67 | btn.click(); 68 | t.equal(click.callCount, 1, 'click still called only once'); 69 | 70 | btn.parentNode.removeChild(btn); 71 | t.end(); 72 | }); 73 | 74 | test('(un)registers an observable event handler', function(t) { 75 | // don't try the focus event, valid tests fail in IE11 76 | 77 | let click = spy(); 78 | let onclick = o(click); 79 | let btn = h('button', { onclick }, 'something'); 80 | document.body.appendChild(btn); 81 | 82 | btn.click(); 83 | t.equal(click.callCount, 1, 'click called'); 84 | 85 | onclick(false); 86 | btn.click(); 87 | t.equal(click.callCount, 1, 'click still called only once'); 88 | 89 | btn.parentNode.removeChild(btn); 90 | t.end(); 91 | }); 92 | 93 | // test('registers event handlers', function(t) { 94 | // let click = spy(); 95 | // let btn = h('button', { events: { click: () => click } }, 'something'); 96 | // document.body.appendChild(btn); 97 | 98 | // btn.click(); 99 | // t.equal(click.callCount, 1, 'click called'); 100 | 101 | // btn.parentNode.removeChild(btn); 102 | // t.end(); 103 | // }); 104 | 105 | // test('can use bindings', function(t) { 106 | // h.bindings.innerHTML = (el, value) => (el.innerHTML = value); 107 | 108 | // let el = h('div', { $innerHTML: 'look ma, no node value' }); 109 | // t.equal(el.outerHTML, '
look ma, no node value
'); 110 | // t.end(); 111 | // }); 112 | 113 | test('sets styles', function(t) { 114 | let div = h('div', { style: { color: 'red' } }); 115 | t.equal(div.style.color, 'red'); 116 | t.end(); 117 | }); 118 | 119 | test('sets styles as text', function(t) { 120 | let div = h('div', { style: 'color: red' }); 121 | t.equal(div.style.color, 'red'); 122 | t.end(); 123 | }); 124 | 125 | // test('sets classes', function(t) { 126 | // let div = h('div', { classList: { play: true, pause: true } }); 127 | // t.assert(div.classList.contains('play')); 128 | // t.assert(div.classList.contains('pause')); 129 | // t.end(); 130 | // }); 131 | 132 | test('sets attributes', function(t) { 133 | let div = h('div', { attrs: { checked: 'checked' } }); 134 | t.assert(div.hasAttribute('checked')); 135 | t.end(); 136 | }); 137 | 138 | test('sets data attributes', function(t) { 139 | let div = h('div', { 'data-value': 5 }); 140 | t.equal(div.getAttribute('data-value'), '5'); // failing for IE9 141 | t.end(); 142 | }); 143 | 144 | test('sets aria attributes', function(t) { 145 | let div = h('div', { 'aria-hidden': true }); 146 | t.equal(div.getAttribute('aria-hidden'), 'true'); 147 | t.end(); 148 | }); 149 | 150 | // test('sets refs', function(t) { 151 | // let ref; 152 | // let div = h('div', { ref: el => (ref = el) }); 153 | // t.equal(div, ref); 154 | // t.end(); 155 | // }); 156 | 157 | test("boolean, number, get to-string'ed", function(t) { 158 | let e = h('p', true, false, 4); 159 | t.assert(e.outerHTML.match(/

truefalse4<\/p>/)); 160 | t.end(); 161 | }); 162 | 163 | // test('unicode selectors', function(t) { 164 | // t.equal(h('.⛄').outerHTML, '

'); 165 | // t.equal(h('span#⛄').outerHTML, ''); 166 | // t.end(); 167 | // }); 168 | 169 | test('can use fragments', function(t) { 170 | const insertCat = () => 'cat'; 171 | let frag = h([h('div', 'First'), insertCat, h('div', 'Last')]); 172 | 173 | const div = document.createElement('div'); 174 | div.appendChild(frag); 175 | t.equal(div.innerHTML, '
First
cat
Last
'); 176 | t.end(); 177 | }); 178 | 179 | test('can use components', function(t) { 180 | const insertCat = ({ id, drink }) => h('div', { id, textContent: drink }); 181 | 182 | let frag = h([ 183 | h('div', 'First'), 184 | h(insertCat, { id: 'cat', drink: 'milk' }), 185 | h('div', 'Last') 186 | ]); 187 | 188 | const div = document.createElement('div'); 189 | div.appendChild(frag); 190 | t.equal( 191 | div.innerHTML, 192 | '
First
milk
Last
' 193 | ); 194 | t.end(); 195 | }); 196 | -------------------------------------------------------------------------------- /test/h/insert-bugs.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { o, h, html } from 'sinuous'; 3 | import { insert } from '../../src/h.js'; 4 | 5 | test('empty fragment clear bug', t => { 6 | let scratch = h('div'); 7 | h(document.body, scratch); 8 | 9 | const value = o(99); 10 | const props = { val: value }; 11 | const comp = ({ val }) => html` 12 |

Hello world

13 |

Bye bye ${val}

14 | `; 15 | 16 | const comp2 = ({ val }) => html` 17 |

Bye world

18 |

Hello hello ${val}

19 | `; 20 | 21 | let active = o(comp); 22 | const res = html` 23 |

Dynamic Components

24 |
25 | ${() => { 26 | const c = active(); 27 | return c(props); 28 | }} 29 | `; 30 | scratch.appendChild(res); 31 | 32 | const emptyFrag = () => document.createDocumentFragment(); 33 | 34 | t.equal(scratch.innerHTML, `

Dynamic Components


Hello world

Bye bye 99

`); 35 | 36 | active(comp2); 37 | t.equal(scratch.innerHTML, `

Dynamic Components


Bye world

Hello hello 99

`); 38 | 39 | active(emptyFrag); 40 | t.equal(scratch.innerHTML, `

Dynamic Components


`); 41 | 42 | active(emptyFrag); 43 | t.equal(scratch.innerHTML, `

Dynamic Components


`); 44 | 45 | t.end(); 46 | }); 47 | 48 | test('insert 9', t => { 49 | let scratch = h('div'); 50 | h(document.body, scratch); 51 | 52 | let active = o(1); 53 | 54 | const Comp = title => html` 55 |
56 | 9 57 | ${() => { 58 | active(); 59 | return html` 60 |
61 | 9 62 | ${() => html` 63 |

${title}

64 | `} 65 |
66 | `; 67 | }} 68 |
`; 69 | 70 | const el = Comp('Yo'); 71 | insert(scratch, el); 72 | 73 | active(2); 74 | active(3); 75 | 76 | t.equal(scratch.innerHTML, '
9
9

Yo

'); 77 | 78 | t.end(); 79 | }); 80 | -------------------------------------------------------------------------------- /test/h/insert-markers.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { h } from 'sinuous'; 3 | import { insert } from '../../src/h.js'; 4 | 5 | // insert with Markers 6 | //
beforeafter
7 | 8 | function insertValue(val) { 9 | const parent = clone(container); 10 | insert(parent, val, parent.childNodes[1]); 11 | return parent; 12 | } 13 | 14 | // IE doesn't clone empty text nodes 15 | function clone(el) { 16 | const cloned = el.cloneNode(true); 17 | cloned.textContent = ''; 18 | [].slice.call(el.childNodes).forEach(n => cloned.appendChild(n.cloneNode())); 19 | return cloned; 20 | } 21 | 22 | const container = document.createElement('div'); 23 | container.appendChild(document.createTextNode('before')); 24 | container.appendChild(document.createTextNode('')); 25 | container.appendChild(document.createTextNode('after')); 26 | 27 | test('inserts nothing for null', t => { 28 | const res = insertValue(null); 29 | t.equal(res.innerHTML, 'beforeafter'); 30 | t.equal(res.childNodes.length, 3); 31 | t.end(); 32 | }); 33 | 34 | test('inserts nothing for undefined', t => { 35 | const res = insertValue(undefined); 36 | t.equal(res.innerHTML, 'beforeafter'); 37 | t.equal(res.childNodes.length, 3); 38 | t.end(); 39 | }); 40 | 41 | test('inserts nothing for false', t => { 42 | const res = insertValue(false); 43 | t.equal(res.innerHTML, 'beforeafter'); 44 | t.equal(res.childNodes.length, 3); 45 | t.end(); 46 | }); 47 | 48 | test('inserts nothing for true', t => { 49 | const res = insertValue(true); 50 | t.equal(res.innerHTML, 'beforeafter'); 51 | t.equal(res.childNodes.length, 3); 52 | t.end(); 53 | }); 54 | 55 | test('inserts nothing for null in array', t => { 56 | const res = insertValue(['a', null, 'b']); 57 | t.equal(res.innerHTML, 'beforeabafter'); 58 | t.equal(res.childNodes.length, 6); 59 | t.end(); 60 | }); 61 | 62 | test('inserts nothing for undefined in array', t => { 63 | const res = insertValue(['a', undefined, 'b']); 64 | t.equal(res.innerHTML, 'beforeabafter'); 65 | t.equal(res.childNodes.length, 6); 66 | t.end(); 67 | }); 68 | 69 | test('can insert strings', t => { 70 | let res = insertValue('foo'); 71 | t.equal(res.innerHTML, 'beforefooafter'); 72 | t.equal(res.childNodes.length, 4); 73 | 74 | res = insertValue(''); 75 | t.equal(res.innerHTML, 'beforeafter'); 76 | t.end(); 77 | }); 78 | 79 | test('can insert a node', t => { 80 | const node = document.createElement('span'); 81 | node.textContent = 'foo'; 82 | t.equal(insertValue(node).innerHTML, 'beforefooafter'); 83 | t.end(); 84 | }); 85 | 86 | test('can re-insert a node, thereby moving it', t => { 87 | var node = document.createElement('span'); 88 | node.textContent = 'foo'; 89 | 90 | const first = insertValue(node), 91 | second = insertValue(node); 92 | 93 | t.equal(first.innerHTML, 'beforeafter'); 94 | t.equal(second.innerHTML, 'beforefooafter'); 95 | t.end(); 96 | }); 97 | 98 | test('can insert an array of strings', t => { 99 | t.equal( 100 | insertValue(['foo', 'bar']).innerHTML, 101 | 'beforefoobarafter', 102 | 'array of strings' 103 | ); 104 | t.end(); 105 | }); 106 | 107 | test('can insert an array of nodes', t => { 108 | const nodes = [document.createElement('span'), document.createElement('div')]; 109 | nodes[0].textContent = 'foo'; 110 | nodes[1].textContent = 'bar'; 111 | t.equal( 112 | insertValue(nodes).innerHTML, 113 | 'beforefoo
bar
after' 114 | ); 115 | t.end(); 116 | }); 117 | 118 | test('can insert a changing array of nodes', t => { 119 | let container = document.createElement('div'), 120 | marker = container.appendChild(document.createTextNode('')), 121 | span1 = document.createElement('span'), 122 | div2 = document.createElement('div'), 123 | span3 = document.createElement('span'), 124 | current; 125 | span1.textContent = '1'; 126 | div2.textContent = '2'; 127 | span3.textContent = '3'; 128 | 129 | current = insert(container, [], marker, current); 130 | t.equal(container.innerHTML, ''); 131 | 132 | current = insert(container, [span1, div2, span3], marker, current); 133 | t.equal(container.innerHTML, '1
2
3'); 134 | 135 | current = insert(container, [div2, span3], marker, current); 136 | t.equal(container.innerHTML, '
2
3'); 137 | 138 | current = insert(container, [div2, span3], marker, current); 139 | t.equal(container.innerHTML, '
2
3'); 140 | 141 | current = insert(container, [span3, div2], marker, current); 142 | t.equal(container.innerHTML, '3
2
'); 143 | 144 | current = insert(container, [], marker, current); 145 | t.equal(container.innerHTML, ''); 146 | 147 | current = insert(container, [span3], marker, current); 148 | t.equal(container.innerHTML, '3'); 149 | 150 | current = insert(container, [div2], marker, current); 151 | t.equal(container.innerHTML, '
2
'); 152 | t.end(); 153 | }); 154 | 155 | test('can insert nested arrays', t => { 156 | t.equal( 157 | insertValue(['foo', ['bar', 'blech']]).innerHTML, 158 | 'beforefoobarblechafter', 159 | 'array of array of strings' 160 | ); 161 | t.end(); 162 | }); 163 | -------------------------------------------------------------------------------- /test/h/insert.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { o, h, html } from 'sinuous'; 3 | import { insert } from '../../src/h.js'; 4 | 5 | const insertValue = val => { 6 | const parent = container.cloneNode(true); 7 | insert(parent, val); 8 | return parent; 9 | }; 10 | 11 | // insert 12 | //
beforeafter
13 | const container = document.createElement('div'); 14 | 15 | test('inserts observable into simple text', t => { 16 | let scratch = h('div'); 17 | h(document.body, scratch); 18 | 19 | const counter = o(0); 20 | scratch.appendChild(html` 21 | Here's a list of items: Count: ${counter} 22 | `); 23 | t.equal(scratch.innerHTML, `Here's a list of items: Count: 0`); 24 | 25 | counter(counter() + 1); 26 | t.equal(scratch.innerHTML, `Here's a list of items: Count: 1`); 27 | 28 | t.end(); 29 | }); 30 | 31 | test('inserts fragments', t => { 32 | const frag = o(html` 33 |

Hello world

34 |

Bye bye

35 | `); 36 | const res = html` 37 |
${frag}
38 | `; 39 | t.equal(res.innerHTML, '

Hello world

Bye bye

'); 40 | t.equal(res.children.length, 2); 41 | 42 | frag( 43 | html` 44 |

Cool

45 |

Beans

46 | ` 47 | ); 48 | t.equal(res.innerHTML, '

Cool

Beans

'); 49 | t.equal(res.children.length, 2); 50 | 51 | frag('make it a string'); 52 | t.equal(res.innerHTML, 'make it a string'); 53 | t.equal(res.childNodes.length, 4); 54 | 55 | frag( 56 | html` 57 |

Cool

58 |

Beans

59 | ` 60 | ); 61 | t.equal(res.innerHTML, '

Cool

Beans

'); 62 | t.equal(res.children.length, 2); 63 | 64 | t.end(); 65 | }); 66 | 67 | test('inserts long fragments', t => { 68 | const frag = o(html` 69 |

Hello world

70 |

Bye bye

71 |

Hello again

72 | `); 73 | const res = html` 74 |
${frag}
75 | `; 76 | t.equal( 77 | res.innerHTML, 78 | '

Hello world

Bye bye

Hello again

' 79 | ); 80 | t.equal(res.children.length, 3); 81 | 82 | frag(html` 83 |

Hello again

84 |

Bye bye

85 |

Hello world

86 | `); 87 | t.equal( 88 | res.innerHTML, 89 | '

Hello again

Bye bye

Hello world

' 90 | ); 91 | t.equal(res.children.length, 3); 92 | 93 | t.end(); 94 | }); 95 | 96 | test('inserts nothing for null', t => { 97 | const res = insertValue(null); 98 | t.equal(res.innerHTML, ''); 99 | t.equal(res.childNodes.length, 0); 100 | t.end(); 101 | }); 102 | 103 | test('inserts nothing for undefined', t => { 104 | const res = insertValue(undefined); 105 | t.equal(res.innerHTML, ''); 106 | t.equal(res.childNodes.length, 0); 107 | t.end(); 108 | }); 109 | 110 | test('inserts nothing for false', t => { 111 | const res = insertValue(false); 112 | t.equal(res.innerHTML, ''); 113 | t.equal(res.childNodes.length, 0); 114 | t.end(); 115 | }); 116 | 117 | test('inserts nothing for true', t => { 118 | const res = insertValue(true); 119 | t.equal(res.innerHTML, ''); 120 | t.equal(res.childNodes.length, 0); 121 | t.end(); 122 | }); 123 | 124 | test('inserts nothing for null in array', t => { 125 | const res = insertValue(['a', null, 'b']); 126 | t.equal(res.innerHTML, 'ab'); 127 | t.equal(res.childNodes.length, 3); 128 | t.end(); 129 | }); 130 | 131 | test('inserts nothing for undefined in array', t => { 132 | const res = insertValue(['a', undefined, 'b']); 133 | t.equal(res.innerHTML, 'ab'); 134 | t.equal(res.childNodes.length, 3); 135 | t.end(); 136 | }); 137 | 138 | test('can insert stringable', t => { 139 | let res = insertValue('foo'); 140 | t.equal(res.innerHTML, 'foo'); 141 | t.equal(res.childNodes.length, 1); 142 | 143 | res = insertValue(11206); 144 | t.equal(res.innerHTML, '11206'); 145 | t.equal(res.childNodes.length, 1); 146 | t.end(); 147 | }); 148 | 149 | test('can insert a node', t => { 150 | const node = document.createElement('span'); 151 | node.textContent = 'foo'; 152 | t.equal(insertValue(node).innerHTML, 'foo'); 153 | t.end(); 154 | }); 155 | 156 | test('can re-insert a node, thereby moving it', t => { 157 | const node = document.createElement('span'); 158 | node.textContent = 'foo'; 159 | 160 | const first = insertValue(node), 161 | second = insertValue(node); 162 | 163 | t.equal(first.innerHTML, ''); 164 | t.equal(second.innerHTML, 'foo'); 165 | t.end(); 166 | }); 167 | 168 | test('can insert an array of strings', t => { 169 | t.equal(insertValue(['foo', 'bar']).innerHTML, 'foobar', 'array of strings'); 170 | t.end(); 171 | }); 172 | 173 | test('can insert an array of nodes', t => { 174 | const nodes = [document.createElement('span'), document.createElement('div')]; 175 | nodes[0].textContent = 'foo'; 176 | nodes[1].textContent = 'bar'; 177 | t.equal(insertValue(nodes).innerHTML, 'foo
bar
'); 178 | t.end(); 179 | }); 180 | 181 | test('can insert a changing array of nodes 1', t => { 182 | var parent = document.createElement('div'), 183 | current = '', 184 | n1 = document.createElement('span'), 185 | n2 = document.createElement('div'), 186 | n3 = document.createElement('span'), 187 | n4 = document.createElement('div'), 188 | orig = [n1, n2, n3, n4]; 189 | 190 | n1.textContent = '1'; 191 | n2.textContent = '2'; 192 | n3.textContent = '3'; 193 | n4.textContent = '4'; 194 | 195 | var origExpected = expected(orig); 196 | 197 | // identity 198 | test([n1, n2, n3, n4]); 199 | 200 | // 1 missing 201 | test([n2, n3, n4]); 202 | test([n1, n3, n4]); 203 | test([n1, n2, n4]); 204 | test([n1, n2, n3]); 205 | 206 | // 2 missing 207 | test([n3, n4]); 208 | test([n2, n4]); 209 | test([n2, n3]); 210 | test([n1, n4]); 211 | test([n1, n3]); 212 | test([n1, n2]); 213 | 214 | // 3 missing 215 | test([n1]); 216 | test([n2]); 217 | test([n3]); 218 | test([n4]); 219 | 220 | // all missing 221 | test([]); 222 | 223 | // swaps 224 | test([n2, n1, n3, n4]); 225 | test([n3, n2, n1, n4]); 226 | test([n4, n2, n3, n1]); 227 | 228 | // rotations 229 | test([n2, n3, n4, n1]); 230 | test([n3, n4, n1, n2]); 231 | test([n4, n1, n2, n3]); 232 | 233 | // reversal 234 | test([n4, n3, n2, n1]); 235 | 236 | function test(array) { 237 | current = insert(parent, array, undefined, current); 238 | t.equal(parent.innerHTML, expected(array)); 239 | current = insert(parent, orig, undefined, current); 240 | t.equal(parent.innerHTML, origExpected); 241 | } 242 | 243 | function expected(array) { 244 | return array.map(n => n.outerHTML).join(''); 245 | } 246 | 247 | t.end(); 248 | }); 249 | 250 | test('can insert nested arrays', t => { 251 | let current = insertValue(['foo', ['bar', 'blech']]); 252 | t.equal(current.innerHTML, 'foobarblech', 'array of array of strings'); 253 | t.end(); 254 | }); 255 | 256 | test('can update text with node', t => { 257 | const parent = container.cloneNode(true); 258 | 259 | let current = insert(parent, '🧬'); 260 | t.equal(parent.innerHTML, '🧬', 'text dna'); 261 | 262 | insert(parent, h('h1', '⛄️'), undefined, current); 263 | t.equal(parent.innerHTML, '

⛄️

'); 264 | t.end(); 265 | }); 266 | 267 | test('can update content with text with marker', t => { 268 | const parent = container.cloneNode(true); 269 | const marker = parent.appendChild(document.createTextNode('')); 270 | 271 | let current = insert(parent, h('h1', '⛄️'), marker); 272 | t.equal(parent.innerHTML, '

⛄️

'); 273 | 274 | insert(parent, '⛄️', marker, current); 275 | t.equal(parent.innerHTML, '⛄️'); 276 | t.end(); 277 | }); 278 | 279 | test('can update content with text and observable with marker', t => { 280 | const parent = container.cloneNode(true); 281 | const marker = parent.appendChild(document.createTextNode('')); 282 | 283 | const reactive = o('reactive'); 284 | const dynamic = o(99); 285 | 286 | insert(parent, h('h1', reactive, '⛄️', dynamic), marker); 287 | t.equal(parent.innerHTML, '

reactive⛄️99

'); 288 | 289 | dynamic(77); 290 | t.equal(parent.innerHTML, '

reactive⛄️77

'); 291 | 292 | reactive(1); 293 | t.equal(parent.innerHTML, '

1⛄️77

'); 294 | 295 | dynamic(''); 296 | t.equal(parent.innerHTML, '

1⛄️

'); 297 | 298 | reactive(''); 299 | t.equal(parent.innerHTML, '

⛄️

'); 300 | 301 | insert(parent, '⛄️', marker, parent.children[0]); 302 | t.equal(parent.innerHTML, '⛄️'); 303 | t.end(); 304 | }); 305 | -------------------------------------------------------------------------------- /test/h/svg.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { hs, svg } from 'sinuous'; 3 | import { normalizeSvg } from '../../test/_utils.js'; 4 | 5 | test('normalizeSvg', function(t) { 6 | // IE11 adds xmlns and has a self closing tags. 7 | t.equal( 8 | normalizeSvg( 9 | '' 10 | ), 11 | '' 12 | ); 13 | t.end(); 14 | }); 15 | 16 | test('supports SVG', function(t) { 17 | const svg = hs( 18 | 'svg', 19 | { class: 'redbox', viewBox: '0 0 100 100' }, 20 | hs('path', { d: 'M 8.74211 7.70899' }) 21 | ); 22 | 23 | t.equal( 24 | normalizeSvg(svg), 25 | '' 26 | ); 27 | t.end(); 28 | }); 29 | 30 | test('can add an array of svg elements', function(t) { 31 | const circles = [1, 2, 3]; 32 | t.equal( 33 | normalizeSvg( 34 | svg` 35 | ${() => circles.map(c => svg``)} 36 | ` 37 | ), 38 | '' 39 | ); 40 | t.end(); 41 | }); 42 | -------------------------------------------------------------------------------- /test/h/utils.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { removeNodes } from '../../src/h.js'; 3 | 4 | test('removeNodes', function(t) { 5 | const parent = document.createElement('div'); 6 | let first = parent.appendChild(document.createComment('')); 7 | parent.appendChild(document.createElement('span')); 8 | let endMark = parent.appendChild(document.createTextNode('')); 9 | 10 | removeNodes(parent, first, endMark); 11 | 12 | t.equal(parent.innerHTML, ''); 13 | t.equal(parent.childNodes.length, 1); 14 | 15 | t.end(); 16 | }); 17 | -------------------------------------------------------------------------------- /test/htm/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | 14 | import test from 'tape'; 15 | import htm from '../../src/htm.js'; 16 | 17 | const h = (tag, props, ...children) => ({ tag, props, children }); 18 | const html = htm.bind(h); 19 | 20 | test('empty', t => { 21 | t.deepEqual(html``, undefined); 22 | t.end(); 23 | }); 24 | 25 | test('single named elements', t => { 26 | t.deepEqual( 27 | html` 28 |
29 | `, 30 | { tag: 'div', props: null, children: [] } 31 | ); 32 | t.deepEqual( 33 | html` 34 |
35 | `, 36 | { tag: 'div', props: null, children: [] } 37 | ); 38 | t.deepEqual( 39 | html` 40 | 41 | `, 42 | { tag: 'span', props: null, children: [] } 43 | ); 44 | t.end(); 45 | }); 46 | 47 | test('multiple root elements', t => { 48 | t.deepEqual( 49 | html` 50 | 51 | `, 52 | h([ 53 | { tag: 'a', props: null, children: [] }, 54 | { tag: 'b', props: null, children: [] }, 55 | { tag: 'c', props: null, children: [] } 56 | ]) 57 | ); 58 | t.end(); 59 | }); 60 | 61 | test('single dynamic tag name', t => { 62 | t.deepEqual( 63 | html` 64 | <${'foo'} /> 65 | `, 66 | { tag: 'foo', props: null, children: [] } 67 | ); 68 | function Foo() {} 69 | t.deepEqual( 70 | html` 71 | <${Foo} /> 72 | `, 73 | { tag: Foo, props: null, children: [] } 74 | ); 75 | t.end(); 76 | }); 77 | 78 | test('single boolean prop', t => { 79 | t.deepEqual( 80 | html` 81 | 82 | `, 83 | { tag: 'a', props: { disabled: true }, children: [] } 84 | ); 85 | t.end(); 86 | }); 87 | 88 | test('two boolean props', t => { 89 | t.deepEqual( 90 | html` 91 | 92 | `, 93 | { tag: 'a', props: { invisible: true, disabled: true }, children: [] } 94 | ); 95 | t.end(); 96 | }); 97 | 98 | test('single prop with empty value', t => { 99 | t.deepEqual( 100 | html` 101 | 102 | `, 103 | { tag: 'a', props: { href: '' }, children: [] } 104 | ); 105 | t.end(); 106 | }); 107 | 108 | test('two props with empty values', t => { 109 | t.deepEqual( 110 | html` 111 | 112 | `, 113 | { tag: 'a', props: { href: '', foo: '' }, children: [] } 114 | ); 115 | t.end(); 116 | }); 117 | 118 | test('single prop with empty name', t => { 119 | t.deepEqual( 120 | html` 121 | 122 | `, 123 | { tag: 'a', props: { '': 'foo' }, children: [] } 124 | ); 125 | t.end(); 126 | }); 127 | 128 | test('single prop with static value', t => { 129 | t.deepEqual( 130 | html` 131 | 132 | `, 133 | { tag: 'a', props: { href: '/hello' }, children: [] } 134 | ); 135 | t.end(); 136 | }); 137 | 138 | test('single prop with static value followed by a single boolean prop', t => { 139 | t.deepEqual( 140 | html` 141 | 142 | `, 143 | { tag: 'a', props: { href: '/hello', b: true }, children: [] } 144 | ); 145 | t.end(); 146 | }); 147 | 148 | test('two props with static values', t => { 149 | t.deepEqual( 150 | html` 151 | 152 | `, 153 | { tag: 'a', props: { href: '/hello', target: '_blank' }, children: [] } 154 | ); 155 | t.end(); 156 | }); 157 | 158 | test('single prop with dynamic value', t => { 159 | t.deepEqual( 160 | html` 161 | 162 | `, 163 | { tag: 'a', props: { href: 'foo' }, children: [] } 164 | ); 165 | t.end(); 166 | }); 167 | 168 | test('slash in the middle of tag name or property name self-closes the element', t => { 169 | t.deepEqual( 170 | html` 171 | 172 | `, 173 | { tag: 'ab', props: null, children: [] } 174 | ); 175 | t.deepEqual( 176 | html` 177 | 178 | `, 179 | { tag: 'abba', props: { pr: true }, children: [] } 180 | ); 181 | t.end(); 182 | }); 183 | 184 | test('slash in a property value does not self-closes the element, unless followed by >', t => { 185 | t.deepEqual(html``, { 186 | tag: 'abba', 187 | props: { prop: 'val/ue' }, 188 | children: [] 189 | }); 190 | t.deepEqual( 191 | html` 192 | 193 | `, 194 | { tag: 'abba', props: { prop: 'value' }, children: [] } 195 | ); 196 | t.deepEqual(html``, { 197 | tag: 'abba', 198 | props: { prop: 'value/' }, 199 | children: [] 200 | }); 201 | t.end(); 202 | }); 203 | 204 | test('two props with dynamic values', t => { 205 | function onClick() {} 206 | t.deepEqual( 207 | html` 208 | 209 | `, 210 | { tag: 'a', props: { href: 'foo', onClick }, children: [] } 211 | ); 212 | t.end(); 213 | }); 214 | 215 | test('prop with multiple static and dynamic values get concatenated as strings', t => { 216 | t.deepEqual( 217 | html` 218 | 219 | `, 220 | { tag: 'a', props: { href: 'beforefooafter' }, children: [] } 221 | ); 222 | t.deepEqual( 223 | html` 224 | 225 | `, 226 | { tag: 'a', props: { href: '11' }, children: [] } 227 | ); 228 | t.deepEqual( 229 | html` 230 | 231 | `, 232 | { tag: 'a', props: { href: '1between1' }, children: [] } 233 | ); 234 | t.deepEqual( 235 | html` 236 | 237 | `, 238 | { tag: 'a', props: { href: '/before/foo/after' }, children: [] } 239 | ); 240 | t.deepEqual( 241 | html` 242 | 243 | `, 244 | { tag: 'a', props: { href: '/before/foo' }, children: [] } 245 | ); 246 | t.end(); 247 | }); 248 | 249 | test('prop with multiple static and observables', t => { 250 | const observableMock = () => 'foo'; 251 | 252 | t.equal( 253 | html` 254 | 255 | `.props.href(), 256 | 'beforefooafter' 257 | ); 258 | t.equal( 259 | html` 260 | 261 | `.props.href(), 262 | 'fooafter' 263 | ); 264 | t.equal( 265 | html` 266 | 267 | `.props.href(), 268 | 'foo1' 269 | ); 270 | t.equal( 271 | html` 272 | 273 | `.props.href(), 274 | '1betweenfoo' 275 | ); 276 | t.equal( 277 | html` 278 | 'foo'}/after /> 279 | `.props.href(), 280 | '/before/foo/after' 281 | ); 282 | t.equal( 283 | html` 284 | 285 | `.props.href(), 286 | '/before/foo' 287 | ); 288 | t.end(); 289 | }); 290 | 291 | test('spread props', t => { 292 | t.deepEqual( 293 | html` 294 | 295 | `, 296 | { tag: 'a', props: { foo: 'bar' }, children: [] } 297 | ); 298 | t.deepEqual( 299 | html` 300 | 301 | `, 302 | { tag: 'a', props: { b: true, foo: 'bar' }, children: [] } 303 | ); 304 | t.deepEqual( 305 | html` 306 | 307 | `, 308 | { tag: 'a', props: { b: true, c: true, foo: 'bar' }, children: [] } 309 | ); 310 | t.deepEqual( 311 | html` 312 | 313 | `, 314 | { tag: 'a', props: { b: true, foo: 'bar' }, children: [] } 315 | ); 316 | t.deepEqual( 317 | html` 318 | 319 | `, 320 | { tag: 'a', props: { b: '1', foo: 'bar' }, children: [] } 321 | ); 322 | t.deepEqual( 323 | html` 324 | 325 | `, 326 | h('a', { x: '1' }, h('b', { y: '2', c: 'bar' })) 327 | ); 328 | t.deepEqual( 329 | html` 330 | d: ${4} 331 | `, 332 | h('a', { b: 2, c: 3 }, 'd: ', 4) 333 | ); 334 | t.deepEqual( 335 | html` 336 | 337 | `, 338 | h('a', { c: 'bar' }, h('b', { d: 'baz' })) 339 | ); 340 | t.end(); 341 | }); 342 | 343 | test('multiple spread props in one element', t => { 344 | t.deepEqual( 345 | html` 346 | 347 | `, 348 | { tag: 'a', props: { foo: 'bar', quux: 'baz' }, children: [] } 349 | ); 350 | t.end(); 351 | }); 352 | 353 | test('mixed spread + static props', t => { 354 | t.deepEqual( 355 | html` 356 | 357 | `, 358 | { tag: 'a', props: { b: true, foo: 'bar' }, children: [] } 359 | ); 360 | t.deepEqual( 361 | html` 362 | 363 | `, 364 | { tag: 'a', props: { b: true, c: true, foo: 'bar' }, children: [] } 365 | ); 366 | t.deepEqual( 367 | html` 368 | 369 | `, 370 | { tag: 'a', props: { b: true, foo: 'bar' }, children: [] } 371 | ); 372 | t.deepEqual( 373 | html` 374 | 375 | `, 376 | { tag: 'a', props: { b: true, c: true, foo: 'bar' }, children: [] } 377 | ); 378 | t.end(); 379 | }); 380 | 381 | test('closing tag', t => { 382 | t.deepEqual( 383 | html` 384 | 385 | `, 386 | { tag: 'a', props: null, children: [] } 387 | ); 388 | t.deepEqual( 389 | html` 390 | 391 | `, 392 | { tag: 'a', props: { b: true }, children: [] } 393 | ); 394 | t.end(); 395 | }); 396 | 397 | test('auto-closing tag', t => { 398 | t.deepEqual( 399 | html` 400 | 401 | `, 402 | { tag: 'a', props: null, children: [] } 403 | ); 404 | t.end(); 405 | }); 406 | 407 | test('non-element roots', t => { 408 | t.deepEqual( 409 | html` 410 | foo 411 | `, 412 | h(['foo']) 413 | ); 414 | t.deepEqual( 415 | html` 416 | ${1} 417 | `, 418 | h([1]) 419 | ); 420 | t.deepEqual( 421 | html` 422 | foo${1} 423 | `, 424 | h(['foo', 1]) 425 | ); 426 | t.deepEqual( 427 | html` 428 | foo${1}bar 429 | `, 430 | h(['foo', 1, 'bar']) 431 | ); 432 | t.end(); 433 | }); 434 | 435 | test('text child', t => { 436 | t.deepEqual( 437 | html` 438 | foo 439 | `, 440 | { tag: 'a', props: null, children: ['foo'] } 441 | ); 442 | t.deepEqual( 443 | html` 444 | foo bar 445 | `, 446 | { tag: 'a', props: null, children: ['foo bar'] } 447 | ); 448 | t.deepEqual( 449 | html` 450 | foo " 451 | `, 452 | { 453 | tag: 'a', 454 | props: null, 455 | children: ['foo "', { tag: 'b', props: null, children: [] }] 456 | } 457 | ); 458 | t.end(); 459 | }); 460 | 461 | test('dynamic child', t => { 462 | t.deepEqual( 463 | html` 464 | ${'foo'} 465 | `, 466 | { tag: 'a', props: null, children: ['foo'] } 467 | ); 468 | t.end(); 469 | }); 470 | 471 | test('mixed text + dynamic children', t => { 472 | t.deepEqual( 473 | html` 474 | ${'foo'}bar 475 | `, 476 | { tag: 'a', props: null, children: ['foo', 'bar'] } 477 | ); 478 | t.deepEqual( 479 | html` 480 | before${'foo'}after 481 | `, 482 | { tag: 'a', props: null, children: ['before', 'foo', 'after'] } 483 | ); 484 | t.deepEqual( 485 | html` 486 | foo${null} 487 | `, 488 | { tag: 'a', props: null, children: ['foo', null] } 489 | ); 490 | t.end(); 491 | }); 492 | 493 | test('element child', t => { 494 | t.deepEqual( 495 | html` 496 | 497 | `, 498 | h('a', null, h('b', null)) 499 | ); 500 | t.end(); 501 | }); 502 | 503 | test('multiple element children', t => { 504 | t.deepEqual( 505 | html` 506 | 507 | `, 508 | h('a', null, h('b', null), h('c', null)) 509 | ); 510 | t.deepEqual( 511 | html` 512 | 513 | `, 514 | h('a', { x: true }, h('b', { y: true }), h('c', { z: true })) 515 | ); 516 | t.deepEqual( 517 | html` 518 | 519 | `, 520 | h('a', { x: '1' }, h('b', { y: '2' }), h('c', { z: '3' })) 521 | ); 522 | t.deepEqual( 523 | html` 524 | 525 | `, 526 | h('a', { x: 1 }, h('b', { y: 2 }), h('c', { z: 3 })) 527 | ); 528 | t.end(); 529 | }); 530 | 531 | test('mixed typed children', t => { 532 | t.deepEqual( 533 | html` 534 | foo 535 | `, 536 | h('a', null, 'foo', h('b', null)) 537 | ); 538 | t.deepEqual( 539 | html` 540 | bar 541 | `, 542 | h('a', null, h('b', null), 'bar') 543 | ); 544 | t.deepEqual( 545 | html` 546 | beforeafter 547 | `, 548 | h('a', null, 'before', h('b', null), 'after') 549 | ); 550 | t.deepEqual( 551 | html` 552 | beforeafter 553 | `, 554 | h('a', null, 'before', h('b', { x: '1' }), 'after') 555 | ); 556 | t.deepEqual( 557 | html` 558 | 559 | before${'foo'} 560 | 561 | ${'bar'}after 562 | 563 | `, 564 | h('a', null, 'before', 'foo', h('b', null), 'bar', 'after') 565 | ); 566 | t.end(); 567 | }); 568 | 569 | test('hyphens (-) are allowed in attribute names', t => { 570 | t.deepEqual( 571 | html` 572 | 573 | `, 574 | h('a', { 'b-c': true }) 575 | ); 576 | t.end(); 577 | }); 578 | 579 | test('NUL characters are allowed in attribute values', t => { 580 | t.deepEqual( 581 | html` 582 | 583 | `, 584 | h('a', { b: '\0' }) 585 | ); 586 | t.deepEqual( 587 | html` 588 | 589 | `, 590 | h('a', { b: '\0', c: 'foo' }) 591 | ); 592 | t.end(); 593 | }); 594 | 595 | test('NUL characters are allowed in text', t => { 596 | t.deepEqual( 597 | html` 598 | \0 599 | `, 600 | h('a', null, '\0') 601 | ); 602 | t.deepEqual( 603 | html` 604 | \0${'foo'} 605 | `, 606 | h('a', null, '\0', 'foo') 607 | ); 608 | t.end(); 609 | }); 610 | 611 | test('cache key should be unique', t => { 612 | html` 613 | 614 | `; 615 | t.deepEqual( 616 | html` 617 | 618 | `, 619 | h('a', { b: '\0' }) 620 | ); 621 | t.notDeepEqual( 622 | html` 623 | ${''}9aaaaaaaaa${''} 624 | `, 625 | html` 626 | ${''}0${''}aaaaaaaaa${''} 627 | ` 628 | ); 629 | t.notDeepEqual( 630 | html` 631 | ${''}0${''}aaaaaaaa${''} 632 | `, 633 | html` 634 | ${''}.8aaaaaaaa${''} 635 | ` 636 | ); 637 | t.end(); 638 | }); 639 | 640 | test('do not mutate spread variables', t => { 641 | const obj = {}; 642 | html` 643 | 644 | `; 645 | t.deepEqual(obj, {}); 646 | t.end(); 647 | }); 648 | 649 | test('ignore HTML comments', t => { 650 | t.deepEqual( 651 | html` 652 | 653 | `, 654 | h('a', null) 655 | ); 656 | t.deepEqual( 657 | html` 658 | 662 | `, 663 | h('a', null) 664 | ); 665 | t.deepEqual( 666 | html` 667 | 668 | `, 669 | h('a', null) 670 | ); 671 | t.deepEqual( 672 | html` 673 | Hello, world 674 | `, 675 | h('a', null) 676 | ); 677 | t.end(); 678 | }); 679 | -------------------------------------------------------------------------------- /test/hydrate/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | mocha: true 4 | 5 | globals: 6 | assert: false 7 | should: false 8 | expect: false 9 | sinon: false 10 | -------------------------------------------------------------------------------- /test/hydrate/selector.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { dhtml, hydrate } from 'sinuous/hydrate'; 3 | import { observable } from 'sinuous'; 4 | 5 | test('hydrate selects root node via id selector', function(t) { 6 | document.body.innerHTML = ` 7 |
8 | 9 |
10 | `; 11 | 12 | const div = hydrate(dhtml` 13 |
14 | 15 |
16 | `); 17 | 18 | t.equal(div, document.querySelector('#root')); 19 | 20 | div.parentNode.removeChild(div); 21 | t.end(); 22 | }); 23 | 24 | test('hydrate selects root node via class selector', function(t) { 25 | document.body.innerHTML = ` 26 |
27 | 28 |
29 | `; 30 | 31 | const div = hydrate(dhtml` 32 |
33 | 34 |
35 | `); 36 | 37 | t.equal(div, document.querySelector('.root.pure')); 38 | t.equal(div, document.querySelector('.root')); 39 | t.equal(div, document.querySelector('.pure')); 40 | 41 | div.parentNode.removeChild(div); 42 | t.end(); 43 | }); 44 | 45 | test('hydrate selects root node via tag selector', function(t) { 46 | document.body.innerHTML = ` 47 | 48 | `; 49 | 50 | const btn = hydrate(dhtml` 51 | 52 | `); 53 | 54 | t.equal(btn, document.querySelector('button')); 55 | 56 | btn.parentNode.removeChild(btn); 57 | t.end(); 58 | }); 59 | 60 | test('hydrate selects root node via partial class selector', function(t) { 61 | document.body.innerHTML = ` 62 |
63 | 64 |
65 | `; 66 | 67 | const isActive = observable(''); 68 | const div = hydrate(dhtml` 69 |
70 | 76 |
77 | `); 78 | 79 | const btn = div.children[0]; 80 | btn.click(); 81 | t.equal(div.className, 'root pure is-active', 'click called'); 82 | 83 | t.equal(div, document.querySelector('.root.pure')); 84 | t.equal(div, document.querySelector('.root')); 85 | t.equal(div, document.querySelector('.pure')); 86 | 87 | div.parentNode.removeChild(div); 88 | t.end(); 89 | }); 90 | -------------------------------------------------------------------------------- /test/hydrate/svg.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { normalizeSvg } from '../_utils.js'; 3 | import { ds, dsvg, hydrate } from 'sinuous/hydrate'; 4 | import { observable } from 'sinuous'; 5 | 6 | test('supports hydrating SVG via hyperscript', function(t) { 7 | document.body.innerHTML = ``; 8 | 9 | const delta = ds( 10 | 'svg', 11 | { class: 'redbox', viewBox: '0 0 100 100' }, 12 | ds('path', { d: 'M 8.74211 7.70899' }) 13 | ); 14 | 15 | const svg = hydrate(delta, document.querySelector('svg')); 16 | 17 | t.equal( 18 | normalizeSvg(svg), 19 | '' 20 | ); 21 | t.end(); 22 | }); 23 | 24 | test('supports hydrating SVG', function(t) { 25 | document.body.innerHTML = ``; 26 | 27 | const delta = dsvg` 28 | 29 | 30 | 31 | `; 32 | 33 | const el = hydrate(delta, document.querySelector('svg')); 34 | 35 | t.equal( 36 | normalizeSvg(el), 37 | '' 38 | ); 39 | t.end(); 40 | }); 41 | 42 | test('can hydrate an array of svg elements', function(t) { 43 | document.body.innerHTML = ``; 44 | 45 | const circles = observable([1, 2, 3]); 46 | const delta = dsvg` 47 | ${() => circles().map(c => dsvg``)} 48 | 49 | ${() => circles().map(c => dsvg``)} 50 | `; 51 | 52 | const el = hydrate(delta, document.querySelector('svg')); 53 | 54 | t.equal( 55 | normalizeSvg(el), 56 | '' 57 | ); 58 | 59 | circles([1, 2, 3, 4]); 60 | 61 | t.equal( 62 | normalizeSvg(el), 63 | '' 64 | ); 65 | 66 | t.end(); 67 | }); 68 | -------------------------------------------------------------------------------- /test/map/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | browser: true 4 | 5 | rules: 6 | no-console: off 7 | -------------------------------------------------------------------------------- /test/map/dispose.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { root } from 'sinuous/observable'; 3 | import { o, h } from 'sinuous'; 4 | import { map } from 'sinuous/map'; 5 | 6 | function lis(str) { 7 | return '
  • ' + str.split(',').join('
  • ') + '
  • '; 8 | } 9 | 10 | test('disposer index works correctly', function(t) { 11 | let one = o(1); 12 | let two = o(2); 13 | let three = o(3); 14 | let four = o(4); 15 | let five = o(5); 16 | // initialize 1, 2, 3, at indexes 0, 1, 2 17 | const list = o([one, two, three]); 18 | const el = h( 19 | 'ul', 20 | map(list, item => h('li', item)) 21 | ); 22 | t.equal(el.innerHTML, lis('1,2,3')); 23 | 24 | // insert 4, 5 at index 0, 1 overwriting disposers[0, 1] 25 | list([four, five, one, two, three]); 26 | t.equal(el.innerHTML, lis('4,5,1,2,3')); 27 | 28 | // remove 1, 2, with disposal index 0, 1, freeing disposers[0, 1] 29 | list([four, five, three]); 30 | t.equal(el.innerHTML, lis('4,5,3')); 31 | 32 | one(11); 33 | two(22); 34 | three(33); 35 | four(44); 36 | five(55); 37 | 38 | t.equal(el.innerHTML, lis('44,55,33')); 39 | 40 | t.end(); 41 | }); 42 | 43 | test('last algorithm insertNodes -> disposes correct index', function(t) { 44 | let one = o(1); 45 | let two = o(2); 46 | let three = o(3); 47 | let four = o(4); 48 | let five = o(5); 49 | const list = o([one, two, three, four, five]); 50 | const el = h( 51 | 'ul', 52 | map(list, item => h('li', item)) 53 | ); 54 | t.equal(el.innerHTML, lis('1,2,3,4,5')); 55 | 56 | list([one, four, five, three, two]); 57 | t.equal(el.innerHTML, lis('1,4,5,3,2')); 58 | 59 | list([one, two, three, four]); 60 | t.equal(el.innerHTML, lis('1,2,3,4')); 61 | 62 | one(11); 63 | two(22); 64 | three(33); 65 | four(44); 66 | five(55); 67 | 68 | t.equal(el.innerHTML, lis('11,22,33,44')); 69 | 70 | t.end(); 71 | }); 72 | 73 | test('swap backward -> disposes correct index', function(t) { 74 | let one = o(1); 75 | let two = o(2); 76 | let three = o(3); 77 | let four = o(4); 78 | let five = o(5); 79 | const list = o([one, two, three, four, five]); 80 | const el = h( 81 | 'ul', 82 | map(list, item => h('li', item)) 83 | ); 84 | t.equal(el.innerHTML, lis('1,2,3,4,5')); 85 | 86 | list([one, two, five, three, four]); 87 | t.equal(el.innerHTML, lis('1,2,5,3,4')); 88 | 89 | list([one, two, three, four]); 90 | t.equal(el.innerHTML, lis('1,2,3,4')); 91 | 92 | one(11); 93 | two(22); 94 | three(33); 95 | four(44); 96 | five(55); 97 | 98 | t.equal(el.innerHTML, lis('11,22,33,44')); 99 | 100 | t.end(); 101 | }); 102 | 103 | test('swap forward -> disposes correct index', function(t) { 104 | let one = o(1); 105 | let two = o(2); 106 | let three = o(3); 107 | let four = o(4); 108 | let five = o(5); 109 | const list = o([one, two, three, four, five]); 110 | const el = h( 111 | 'ul', 112 | map(list, item => h('li', item)) 113 | ); 114 | t.equal(el.innerHTML, lis('1,2,3,4,5')); 115 | 116 | list([two, three, one, four, five]); 117 | t.equal(el.innerHTML, lis('2,3,1,4,5')); 118 | 119 | list([two, three, four, five]); 120 | t.equal(el.innerHTML, lis('2,3,4,5')); 121 | 122 | one(11); 123 | two(22); 124 | three(33); 125 | four(44); 126 | five(55); 127 | 128 | t.equal(el.innerHTML, lis('22,33,44,55')); 129 | 130 | t.end(); 131 | }); 132 | 133 | test('removing one observable diposes correct index', function(t) { 134 | let two = o(2); 135 | let four = o(4); 136 | let six = o(6); 137 | const list = o([1, two, 3, four, 5, six, 7]); 138 | const el = h( 139 | 'ul', 140 | map(list, item => h('li', item)) 141 | ); 142 | t.equal(el.innerHTML, lis('1,2,3,4,5,6,7')); 143 | 144 | list([1, two, four, 3, 5, six, 7]); 145 | t.equal(el.innerHTML, lis('1,2,4,3,5,6,7')); 146 | 147 | list([1, two, 3, six, 7]); 148 | four(44); 149 | two(22); 150 | t.equal(el.innerHTML, lis('1,22,3,6,7')); 151 | 152 | two(2); 153 | four(4444); 154 | six(66); 155 | t.equal(el.innerHTML, lis('1,2,3,66,7')); 156 | 157 | t.end(); 158 | }); 159 | 160 | test('explicit dispose works and disposes observables', function(t) { 161 | let four = o(4); 162 | const list = o([1, 2, 3, four]); 163 | let dispose; 164 | const el = root(d => { 165 | dispose = d; 166 | return h( 167 | 'ul', 168 | map(list, item => h('li', item)) 169 | ); 170 | }); 171 | t.equal(el.innerHTML, lis('1,2,3,4')); 172 | 173 | list([1, 2, four, 3]); 174 | t.equal(el.innerHTML, lis('1,2,4,3')); 175 | 176 | four(44); 177 | t.equal(el.innerHTML, lis('1,2,44,3')); 178 | 179 | dispose(); 180 | 181 | four(44444); 182 | t.equal(el.innerHTML, lis('1,2,44,3')); 183 | 184 | list([9, 7, 8, 6]); 185 | t.equal(el.innerHTML, lis('1,2,44,3')); 186 | 187 | t.end(); 188 | }); 189 | 190 | test('emptying list disposes observables', function(t) { 191 | let four = o(4); 192 | const list = o([1, 2, 3, four]); 193 | 194 | const el = h( 195 | 'ul', 196 | map(list, item => h('li', item)) 197 | ); 198 | t.equal(el.innerHTML, lis('1,2,3,4')); 199 | 200 | list([1, 2, four, 3]); 201 | t.equal(el.innerHTML, lis('1,2,4,3')); 202 | 203 | four(44); 204 | t.equal(el.innerHTML, lis('1,2,44,3')); 205 | 206 | list([]); 207 | four(44444); 208 | t.equal(el.innerHTML, ''); 209 | 210 | t.end(); 211 | }); 212 | -------------------------------------------------------------------------------- /test/map/map-basic.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { root } from 'sinuous/observable'; 3 | import { o, h, html } from 'sinuous'; 4 | import { map } from 'sinuous/map'; 5 | 6 | const list = o([]); 7 | const show = o(true); 8 | const fallback = o(html`
    `); 9 | 10 | let div; 11 | let dispose; 12 | root(d => { 13 | dispose = d; 14 | div = html` 15 |
    16 | ${() => show() 17 | ? html`${map(list, item => html`${item}`)}` 18 | : html`${fallback}` 19 | } 20 |
    21 | `; 22 | }); 23 | 24 | test('Basic map - create', t => { 25 | list([['a', 1], ['b', 2], ['c', 3], ['d', 4]]); 26 | t.equal(div.innerHTML, 'a1b2c3d4'); 27 | t.end(); 28 | }); 29 | 30 | test('Basic map - update', t => { 31 | list([['b', 2, 99], ['a', 1], ['c']]); 32 | t.equal(div.innerHTML, 'b299a1c'); 33 | t.end(); 34 | }); 35 | 36 | test('Basic map - clear', t => { 37 | list([]); 38 | t.equal(div.innerHTML, ''); 39 | t.end(); 40 | }); 41 | 42 | test('Basic map - update 2', t => { 43 | show(false); 44 | list([['b', 2, 99], ['a', 1], ['c']]); 45 | t.equal(div.innerHTML, '
    '); 46 | t.end(); 47 | }); 48 | 49 | test('Basic map - clear 2', t => { 50 | show(true); 51 | list([]); 52 | fallback(''); 53 | t.equal(div.innerHTML, ''); 54 | t.end(); 55 | }); 56 | 57 | test('Basic map - update 3', t => { 58 | div.insertBefore(h('i'), div.firstChild); 59 | div.insertBefore(h('b'), div.firstChild); 60 | 61 | div.appendChild(h('i')); 62 | div.appendChild(h('b')); 63 | 64 | list([['b', 2, 99], ['a', 1], ['c']]); 65 | t.equal(div.innerHTML, 'b299a1c'); 66 | t.end(); 67 | }); 68 | 69 | test('Basic map - update 4', t => { 70 | list([]); 71 | show(false); 72 | fallback(html`
    `); 73 | t.equal(div.innerHTML, '
    '); 74 | t.end(); 75 | }); 76 | 77 | test('Basic map - update 5', t => { 78 | show(true); 79 | fallback(11); 80 | t.equal(div.innerHTML, ''); 81 | t.end(); 82 | }); 83 | 84 | test('Basic map - dispose', t => { 85 | dispose(); 86 | t.end(); 87 | }); 88 | -------------------------------------------------------------------------------- /test/map/map-fragments.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import * as api from 'sinuous/observable'; 3 | import { o, h } from 'sinuous'; 4 | import { map } from 'sinuous/map'; 5 | 6 | const root = api.root; 7 | 8 | let div; 9 | const n1 = 'a', 10 | n2 = 'b', 11 | n3 = 'c', 12 | n4 = 'd'; 13 | const list = o([n1, n2, n3, n4]); 14 | let dispose; 15 | const Component = () => 16 | root(d => { 17 | dispose = d; 18 | div = h( 19 | 'div', 20 | map(list, item => h([item, item])) 21 | ); 22 | }); 23 | 24 | function apply(t, array) { 25 | list(array); 26 | t.equal(div.innerHTML, array.map(p => `${p}${p}`).join('')); 27 | list([n1, n2, n3, n4]); 28 | t.equal(div.innerHTML, 'aabbccdd'); 29 | } 30 | 31 | test('Create map control flow', t => { 32 | Component(); 33 | 34 | t.equal(div.innerHTML, 'aabbccdd'); 35 | t.end(); 36 | }); 37 | 38 | test('1 missing', t => { 39 | apply(t, [n2, n3, n4]); 40 | apply(t, [n1, n3, n4]); 41 | apply(t, [n1, n2, n4]); 42 | apply(t, [n1, n2, n3]); 43 | t.end(); 44 | }); 45 | 46 | test('2 missing', t => { 47 | apply(t, [n3, n4]); 48 | apply(t, [n2, n4]); 49 | apply(t, [n2, n3]); 50 | apply(t, [n1, n4]); 51 | apply(t, [n1, n3]); 52 | apply(t, [n1, n2]); 53 | t.end(); 54 | }); 55 | 56 | test('3 missing', t => { 57 | apply(t, [n1]); 58 | apply(t, [n2]); 59 | apply(t, [n3]); 60 | apply(t, [n4]); 61 | t.end(); 62 | }); 63 | 64 | test('all missing', t => { 65 | apply(t, []); 66 | t.end(); 67 | }); 68 | 69 | test('swaps', t => { 70 | apply(t, [n2, n1, n3, n4]); 71 | apply(t, [n3, n2, n1, n4]); 72 | apply(t, [n4, n2, n3, n1]); 73 | t.end(); 74 | }); 75 | 76 | test('rotations', t => { 77 | apply(t, [n2, n3, n4, n1]); 78 | apply(t, [n3, n4, n1, n2]); 79 | apply(t, [n4, n1, n2, n3]); 80 | t.end(); 81 | }); 82 | 83 | test('reversal', t => { 84 | apply(t, [n4, n3, n2, n1]); 85 | t.end(); 86 | }); 87 | 88 | test('full replace', t => { 89 | apply(t, ['e', 'f', 'g', 'h']); 90 | t.end(); 91 | }); 92 | 93 | test('swap backward edge', t => { 94 | list(['milk', 'bread', 'chips', 'cookie', 'honey']); 95 | list(['chips', 'bread', 'cookie', 'milk', 'honey']); 96 | t.end(); 97 | }); 98 | 99 | test('dispose', t => { 100 | dispose(); 101 | t.end(); 102 | }); 103 | -------------------------------------------------------------------------------- /test/map/map-objects.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import * as api from 'sinuous/observable'; 3 | import { o, h, html } from 'sinuous'; 4 | import { map } from 'sinuous/map'; 5 | 6 | const root = api.root; 7 | 8 | function divs(str) { 9 | return '
    ' + str.split(',').join('
    ') + '
    '; 10 | } 11 | 12 | const one = { text: o(1) }; 13 | const two = { text: o(2) }; 14 | const three = { text: o(3) }; 15 | const four = { text: o(4) }; 16 | const five = { text: o(5) }; 17 | const list = o([one, two, three, four, five]); 18 | 19 | const div = document.createElement('div'); 20 | let dispose; 21 | root(d => { 22 | dispose = d; 23 | div.appendChild( 24 | map( 25 | list, 26 | item => 27 | html` 28 |
    ${item.text}
    29 | ` 30 | ) 31 | ); 32 | }); 33 | 34 | test('Object reference - create', t => { 35 | t.equal(div.innerHTML, divs('1,2,3,4,5')); 36 | t.end(); 37 | }); 38 | 39 | test('Object reference - update', t => { 40 | list([three, one, four, two]); 41 | t.equal(div.innerHTML, divs('3,1,4,2')); 42 | t.end(); 43 | }); 44 | 45 | test('Object reference - update 2', t => { 46 | list([one, three, two, four]); 47 | t.equal(div.innerHTML, divs('1,3,2,4')); 48 | t.end(); 49 | }); 50 | 51 | test('Object reference - update 3', t => { 52 | list([five, three, four]); 53 | t.equal(div.innerHTML, divs('5,3,4')); 54 | t.end(); 55 | }); 56 | 57 | test('Object reference - clear', t => { 58 | list([]); 59 | t.equal(div.innerHTML, ''); 60 | t.end(); 61 | }); 62 | 63 | test('Object reference - dispose', t => { 64 | dispose(); 65 | t.end(); 66 | }); 67 | -------------------------------------------------------------------------------- /test/map/map.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import * as api from 'sinuous/observable'; 3 | import { o, h, html } from 'sinuous'; 4 | import { map } from 'sinuous/map'; 5 | 6 | const root = api.root; 7 | 8 | let div; 9 | const n1 = 'a', 10 | n2 = 'b', 11 | n3 = 'c', 12 | n4 = 'd'; 13 | const list = o([n1, n2, n3, n4]); 14 | let dispose; 15 | const Component = () => 16 | root(d => { 17 | dispose = d; 18 | div = h( 19 | 'div', 20 | map(list, item => html`${item}`) 21 | ); 22 | }); 23 | 24 | function apply(t, array) { 25 | list(array); 26 | t.equal(div.innerHTML, array.join('')); 27 | list([n1, n2, n3, n4]); 28 | t.equal(div.innerHTML, 'abcd'); 29 | } 30 | 31 | test('Create map control flow', t => { 32 | Component(); 33 | 34 | t.equal(div.innerHTML, 'abcd'); 35 | t.end(); 36 | }); 37 | 38 | test('1 missing', t => { 39 | apply(t, [n2, n3, n4]); 40 | apply(t, [n1, n3, n4]); 41 | apply(t, [n1, n2, n4]); 42 | apply(t, [n1, n2, n3]); 43 | t.end(); 44 | }); 45 | 46 | test('2 missing', t => { 47 | apply(t, [n3, n4]); 48 | apply(t, [n2, n4]); 49 | apply(t, [n2, n3]); 50 | apply(t, [n1, n4]); 51 | apply(t, [n1, n3]); 52 | apply(t, [n1, n2]); 53 | t.end(); 54 | }); 55 | 56 | test('3 missing', t => { 57 | apply(t, [n1]); 58 | apply(t, [n2]); 59 | apply(t, [n3]); 60 | apply(t, [n4]); 61 | t.end(); 62 | }); 63 | 64 | test('all missing', t => { 65 | apply(t, []); 66 | t.end(); 67 | }); 68 | 69 | test('swaps', t => { 70 | apply(t, [n2, n1, n3, n4]); 71 | apply(t, [n3, n2, n1, n4]); 72 | apply(t, [n4, n2, n3, n1]); 73 | t.end(); 74 | }); 75 | 76 | test('rotations', t => { 77 | apply(t, [n2, n3, n4, n1]); 78 | apply(t, [n3, n4, n1, n2]); 79 | apply(t, [n4, n1, n2, n3]); 80 | t.end(); 81 | }); 82 | 83 | test('reversal', t => { 84 | apply(t, [n4, n3, n2, n1]); 85 | t.end(); 86 | }); 87 | 88 | test('full replace', t => { 89 | apply(t, ['e', 'f', 'g', 'h']); 90 | t.end(); 91 | }); 92 | 93 | test('swap backward edge', t => { 94 | list(['milk', 'bread', 'chips', 'cookie', 'honey']); 95 | list(['chips', 'bread', 'cookie', 'milk', 'honey']); 96 | t.end(); 97 | }); 98 | 99 | test('dispose', t => { 100 | dispose(); 101 | t.end(); 102 | }); 103 | -------------------------------------------------------------------------------- /test/observable/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | mocha: true 4 | 5 | globals: 6 | assert: false 7 | should: false 8 | expect: false 9 | sinon: false 10 | 11 | rules: 12 | fp/no-rest-parameters: off 13 | -------------------------------------------------------------------------------- /test/observable/S.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import spy from 'ispy'; 3 | import { o, S } from 'sinuous/observable'; 4 | 5 | // Tests from S.js 6 | 7 | test('generates a function', function(t) { 8 | t.plan(1); 9 | var f = S(function() { 10 | return 1; 11 | }); 12 | t.assert(typeof f === 'function'); 13 | }); 14 | 15 | test('returns initial value of wrapped function', function(t) { 16 | t.plan(1); 17 | var f = S(function() { 18 | return 1; 19 | }); 20 | t.equal(f(), 1); 21 | }); 22 | 23 | test('occurs once intitially', function(t) { 24 | var callSpy = spy(); 25 | S(callSpy); 26 | t.equal(callSpy.callCount, 1); 27 | t.end(); 28 | }); 29 | 30 | test('does not re-occur when read', function(t) { 31 | var callSpy = spy(), 32 | f = S(callSpy); 33 | f(); 34 | f(); 35 | f(); 36 | 37 | t.equal(callSpy.callCount, 1); 38 | t.end(); 39 | }); 40 | 41 | test('updates when S.data is set', function(t) { 42 | var d = o(1), 43 | fevals = 0; 44 | 45 | S(function() { 46 | fevals++; 47 | return d(); 48 | }); 49 | fevals = 0; 50 | 51 | d(1); 52 | t.equal(fevals, 1); 53 | t.end(); 54 | }); 55 | 56 | test('does not update when S.data is read', function(t) { 57 | var d = o(1), 58 | fevals = 0; 59 | 60 | S(function() { 61 | fevals++; 62 | return d(); 63 | }); 64 | fevals = 0; 65 | 66 | d(); 67 | t.equal(fevals, 0); 68 | t.end(); 69 | }); 70 | 71 | test('updates return value', function(t) { 72 | var d = o(1), 73 | f = S(function() { 74 | return d(); 75 | }); 76 | 77 | d(2); 78 | t.equal(f(), 2); 79 | t.end(); 80 | }); 81 | 82 | test('set works from other computed', function(t) { 83 | var banana = o(); 84 | var count = 0; 85 | S(() => { 86 | count++; 87 | return banana() + ' shake'; 88 | }); 89 | t.equal(count, 1); 90 | 91 | var carrot = o(); 92 | S(() => { 93 | console.log('banana false'); 94 | banana(false); 95 | 96 | carrot() + ' soup'; 97 | 98 | console.log('banana true'); 99 | banana(true); 100 | }); 101 | 102 | carrot('carrot'); 103 | t.equal(count, 5); 104 | 105 | banana(false); 106 | t.equal(count, 6); 107 | 108 | t.end(); 109 | }); 110 | 111 | (function() { 112 | var i, j, e, fevals, f; 113 | 114 | function init() { 115 | i = o(true); 116 | j = o(1); 117 | e = o(2); 118 | fevals = 0; 119 | f = S(function() { 120 | fevals++; 121 | return i() ? j() : e(); 122 | }); 123 | fevals = 0; 124 | } 125 | 126 | test('updates on active dependencies', function(t) { 127 | init(); 128 | j(5); 129 | t.equal(fevals, 1); 130 | t.equal(f(), 5); 131 | t.end(); 132 | }); 133 | 134 | test('does not update on inactive dependencies', function(t) { 135 | init(); 136 | e(5); 137 | t.equal(fevals, 0); 138 | t.equal(f(), 1); 139 | t.end(); 140 | }); 141 | 142 | test('deactivates obsolete dependencies', function(t) { 143 | init(); 144 | i(false); 145 | fevals = 0; 146 | j(5); 147 | t.equal(fevals, 0); 148 | t.end(); 149 | }); 150 | 151 | test('activates new dependencies', function(t) { 152 | init(); 153 | i(false); 154 | fevals = 0; 155 | e(5); 156 | t.equal(fevals, 1); 157 | t.end(); 158 | }); 159 | })(); 160 | 161 | test('does not register a dependency', function(t) { 162 | var fevals = 0, 163 | d; 164 | 165 | S(function() { 166 | fevals++; 167 | d = o(1); 168 | }); 169 | 170 | fevals = 0; 171 | d(2); 172 | t.equal(fevals, 0); 173 | t.end(); 174 | }); 175 | 176 | test('reads as undefined', function(t) { 177 | var f = S(function() {}); 178 | t.equal(f(), undefined); 179 | t.end(); 180 | }); 181 | 182 | test('reduces seed value', function(t) { 183 | var a = o(5), 184 | f = S(function(v) { 185 | return v + a(); 186 | }, 5); 187 | t.equal(f(), 10); 188 | a(6); 189 | t.equal(f(), 16); 190 | t.end(); 191 | }); 192 | 193 | (function() { 194 | var d, fcount, f, gcount, g; 195 | 196 | function init() { 197 | (d = o(1)), 198 | (fcount = 0), 199 | (f = S(function() { 200 | fcount++; 201 | return d(); 202 | })), 203 | (gcount = 0), 204 | (g = S(function() { 205 | gcount++; 206 | return f(); 207 | })); 208 | } 209 | 210 | test('does not cause re-evaluation', function(t) { 211 | init(); 212 | t.equal(fcount, 1); 213 | t.end(); 214 | }); 215 | 216 | test('does not occur from a read', function(t) { 217 | init(); 218 | f(); 219 | t.equal(gcount, 1); 220 | t.end(); 221 | }); 222 | 223 | test('does not occur from a read of the watcher', function(t) { 224 | init(); 225 | g(); 226 | t.equal(gcount, 1); 227 | t.end(); 228 | }); 229 | 230 | test('occurs when computation updates', function(t) { 231 | init(); 232 | d(2); 233 | t.equal(fcount, 2); 234 | t.equal(gcount, 2); 235 | t.equal(g(), 2); 236 | t.end(); 237 | }); 238 | })(); 239 | 240 | // test("throws when continually setting a direct dependency", function () { 241 | // var d = S.data(1); 242 | 243 | // t.equal(function () { 244 | // S(function () { d(); d(2); }); 245 | // }).toThrow(); 246 | // }); 247 | 248 | // test("throws when continually setting an indirect dependency", function () { 249 | // var d = S.data(1), 250 | // f1 = S(function () { return d(); }), 251 | // f2 = S(function () { return f1(); }), 252 | // f3 = S(function () { return f2(); }); 253 | 254 | // t.equal(function () { 255 | // S(function () { f3(); d(2); }); 256 | // }).toThrow(); 257 | // }); 258 | 259 | // test("throws when cycle created by modifying a branch", function () { 260 | // var d = S.data(1), 261 | // f = S(function () { return f ? f() : d(); }); 262 | 263 | // t.equal(function () { d(0); }).toThrow(); 264 | // }); 265 | 266 | test('propagates in topological order', function(t) { 267 | // 268 | // c1 269 | // / \ 270 | // / \ 271 | // b1 b2 272 | // \ / 273 | // \ / 274 | // a1 275 | // 276 | var seq = '', 277 | a1 = o(true), 278 | b1 = S(function() { 279 | a1(); 280 | seq += 'b1'; 281 | }), 282 | b2 = S(function() { 283 | a1(); 284 | seq += 'b2'; 285 | }), 286 | c1 = S(function() { 287 | b1(), b2(); 288 | seq += 'c1'; 289 | }); 290 | 291 | seq = ''; 292 | a1(true); 293 | 294 | t.equal(seq, 'b1b2c1'); 295 | t.end(); 296 | }); 297 | 298 | test('only propagates once with linear convergences', function(t) { 299 | // d 300 | // | 301 | // +---+---+---+---+ 302 | // v v v v v 303 | // f1 f2 f3 f4 f5 304 | // | | | | | 305 | // +---+---+---+---+ 306 | // v 307 | // g 308 | var d = o(0), 309 | f1 = S(function() { 310 | return d(); 311 | }), 312 | f2 = S(function() { 313 | return d(); 314 | }), 315 | f3 = S(function() { 316 | return d(); 317 | }), 318 | f4 = S(function() { 319 | return d(); 320 | }), 321 | f5 = S(function() { 322 | return d(); 323 | }), 324 | gcount = 0, 325 | g = S(function() { 326 | gcount++; 327 | return f1() + f2() + f3() + f4() + f5(); 328 | }); 329 | 330 | gcount = 0; 331 | d(0); 332 | t.equal(gcount, 1); 333 | t.end(); 334 | }); 335 | 336 | test('only propagates once with exponential convergence', function(t) { 337 | // d 338 | // | 339 | // +---+---+ 340 | // v v v 341 | // f1 f2 f3 342 | // \ | / 343 | // O 344 | // / | \ 345 | // v v v 346 | // g1 g2 g3 347 | // +---+---+ 348 | // v 349 | // h 350 | var d = o(0), 351 | f1 = S(function() { 352 | return d(); 353 | }), 354 | f2 = S(function() { 355 | return d(); 356 | }), 357 | f3 = S(function() { 358 | return d(); 359 | }), 360 | g1 = S(function() { 361 | return f1() + f2() + f3(); 362 | }), 363 | g2 = S(function() { 364 | return f1() + f2() + f3(); 365 | }), 366 | g3 = S(function() { 367 | return f1() + f2() + f3(); 368 | }), 369 | hcount = 0, 370 | h = S(function() { 371 | hcount++; 372 | return g1() + g2() + g3(); 373 | }); 374 | 375 | hcount = 0; 376 | d(0); 377 | t.equal(hcount, 1); 378 | t.end(); 379 | }); 380 | -------------------------------------------------------------------------------- /test/observable/child.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import spy from 'ispy'; 3 | import { o, S, transaction, observable, sample } from 'sinuous/observable'; 4 | 5 | test('parent cleans up inner subscriptions', function(t) { 6 | let i = 0; 7 | 8 | const data = o(null); 9 | const cache = o(false); 10 | 11 | let childValue; 12 | let childValue2; 13 | 14 | const child = d => { 15 | S(function nested() { 16 | childValue = d(); 17 | i++; 18 | }); 19 | return 'Hi'; 20 | }; 21 | 22 | const child2 = d => { 23 | S(function nested2() { 24 | childValue2 = d(); 25 | }); 26 | return 'Hi'; 27 | }; 28 | 29 | S(function cacheFun(prev) { 30 | const d = !!data(); 31 | if (d === prev) { 32 | return prev; 33 | } 34 | cache(d); 35 | return d; 36 | }); 37 | 38 | // Run 1st time 39 | S(function memo() { 40 | cache(); 41 | child2(data); 42 | child(data); 43 | }); 44 | 45 | // 2nd 46 | data('name'); 47 | t.equal(childValue, 'name'); 48 | t.equal(childValue2, 'name'); 49 | 50 | // 3rd 51 | data(null); 52 | t.equal(childValue, null); 53 | t.equal(childValue2, null); 54 | 55 | // 4th 56 | data('name2'); 57 | t.equal(childValue, 'name2'); 58 | t.equal(childValue2, 'name2'); 59 | 60 | t.equal(i, 4); 61 | t.end(); 62 | }); 63 | 64 | test('parent cleans up inner conditional subscriptions', function(t) { 65 | let i = 0; 66 | 67 | const data = o(null); 68 | const cache = o(false); 69 | 70 | let childValue; 71 | 72 | const child = d => { 73 | S(function nested() { 74 | childValue = d(); 75 | i++; 76 | }); 77 | return 'Hi'; 78 | }; 79 | 80 | S(function cacheFun(prev) { 81 | const d = !!data(); 82 | if (d === prev) { 83 | return prev; 84 | } 85 | cache(d); 86 | return d; 87 | }); 88 | 89 | const memo = S(() => { 90 | const c = cache(); 91 | return c ? child(data) : undefined; 92 | }); 93 | 94 | let view; 95 | S(() => (view = memo())); 96 | 97 | t.equal(view, undefined); 98 | 99 | // Run 1st time 100 | data('name'); 101 | t.equal(childValue, 'name'); 102 | 103 | t.equal(view, 'Hi'); 104 | 105 | // 2nd 106 | data('name2'); 107 | t.equal(childValue, 'name2'); 108 | 109 | // data is null -> cache is false -> child is not run here 110 | data(null); 111 | t.equal(childValue, 'name2'); 112 | 113 | t.equal(view, undefined); 114 | 115 | t.equal(i, 2); 116 | t.end(); 117 | }); 118 | 119 | test('parent cleans up inner conditional subscriptions w/ other child', function(t) { 120 | let i = 0; 121 | 122 | const data = o(null); 123 | const cache = o(false); 124 | 125 | let childValue; 126 | let childValue2; 127 | 128 | const child = d => { 129 | S(function nested() { 130 | childValue = d(); 131 | i++; 132 | }); 133 | return 'Hi'; 134 | }; 135 | 136 | const child2 = d => { 137 | S(function nested2() { 138 | childValue2 = d(); 139 | }); 140 | return 'Hi'; 141 | }; 142 | 143 | S(function cacheFun(prev) { 144 | const d = !!data(); 145 | if (d === prev) { 146 | return prev; 147 | } 148 | cache(d); 149 | return d; 150 | }); 151 | 152 | // Run 1st time 153 | const memo = S(() => { 154 | const c = cache(); 155 | child2(data); 156 | return c ? child(data) : undefined; 157 | }); 158 | 159 | let view; 160 | S(() => (view = memo())); 161 | 162 | t.equal(view, undefined); 163 | 164 | // 2nd 165 | data('name'); 166 | t.equal(childValue, 'name'); 167 | t.equal(childValue2, 'name'); 168 | 169 | t.equal(view, 'Hi'); 170 | 171 | // 3rd 172 | data(null); 173 | t.equal(childValue, 'name'); 174 | t.equal(childValue2, null); 175 | 176 | t.equal(view, undefined); 177 | 178 | // 4th 179 | data('name2'); 180 | t.equal(childValue, 'name2'); 181 | t.equal(childValue2, 'name2'); 182 | 183 | t.equal(i, 2); 184 | t.end(); 185 | }); 186 | 187 | test('deeply nested cleanup of subscriptions', function(t) { 188 | const data = o(null); 189 | 190 | const spy1 = spy(); 191 | spy1.delegate = () => { 192 | spy2(); 193 | }; 194 | 195 | const spy2 = spy(); 196 | spy2.delegate = () => { 197 | data(); 198 | child3(); 199 | }; 200 | 201 | const spy3 = spy(); 202 | spy3.delegate = () => { 203 | data(); 204 | }; 205 | 206 | const child1 = () => { 207 | S(spy1); 208 | return 'Hi'; 209 | }; 210 | 211 | const child3 = () => { 212 | S(spy3); 213 | return 'Hi'; 214 | }; 215 | 216 | S(() => { 217 | child1(); 218 | }); 219 | 220 | t.equal(spy1.callCount, 1); 221 | t.equal(spy3.callCount, 1); 222 | 223 | data('banana'); 224 | 225 | t.equal(spy3.callCount, 2); 226 | 227 | t.end(); 228 | }); 229 | 230 | test('insures that new dependencies are updated before dependee', function(t) { 231 | var order = ''; 232 | var a = o(0); 233 | 234 | var b = S(function x() { 235 | order += 'b'; 236 | console.log('B'); 237 | return a() + 1; 238 | }); 239 | 240 | var c = S(function y() { 241 | order += 'c'; 242 | console.log('C'); 243 | return b() || d(); 244 | }); 245 | 246 | function z() { 247 | order += 'd'; 248 | console.log('D'); 249 | return a() + 10; 250 | } 251 | var d = S(z); 252 | 253 | t.equal(order, 'bcd', '1st bcd test'); 254 | 255 | order = ''; 256 | a(-1); 257 | 258 | t.equal(b(), 0, 'b equals 0'); 259 | t.equal(order, 'bcd', '2nd bcd test'); 260 | t.equal(d(), 9, 'd equals 9'); 261 | t.equal(c(), 9, 'c equals d(9)'); 262 | 263 | order = ''; 264 | a(0); 265 | 266 | t.equal(order, 'bc', '3rd bcd test'); 267 | t.equal(c(), 1); 268 | t.end(); 269 | }); 270 | 271 | test('unrelated state via transaction updates view correctly', function(t) { 272 | const data = observable(null), 273 | trigger = observable(false), 274 | cache = observable(sample(() => !!trigger())), 275 | child = data => { 276 | S(() => console.log('nested', data().length)); 277 | return 'Hi'; 278 | }; 279 | 280 | S(prev => { 281 | const d = !!data(); 282 | if (d === prev) return prev; 283 | cache(d); 284 | return d; 285 | }); 286 | 287 | const memo = S(() => (cache() ? child(data) : undefined)); 288 | 289 | let view; 290 | S(() => (view = memo())); 291 | t.equal(view, undefined); 292 | 293 | transaction(() => { 294 | trigger(true); 295 | data('name'); 296 | }); 297 | t.equal(view, 'Hi'); 298 | 299 | transaction(() => { 300 | trigger(true); 301 | data('name2'); 302 | }); 303 | 304 | transaction(() => { 305 | data(undefined); 306 | trigger(false); 307 | }); 308 | t.equal(view, undefined); 309 | 310 | t.end(); 311 | }); 312 | -------------------------------------------------------------------------------- /test/observable/dispose.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { o, S, root } from 'sinuous/observable'; 3 | 4 | test("disables updates and sets computation's value to undefined", function(t) { 5 | root(function(dispose) { 6 | var c = 0, 7 | d = o(0), 8 | f = S(function() { 9 | c++; 10 | return d(); 11 | }); 12 | 13 | t.equal(c, 1); 14 | t.equal(f(), 0); 15 | 16 | d(1); 17 | 18 | t.equal(c, 2); 19 | t.equal(f(), 1); 20 | 21 | dispose(); 22 | 23 | d(2); 24 | 25 | t.equal(c, 2); 26 | t.equal(f(), 1); 27 | t.end(); 28 | }); 29 | }); 30 | 31 | // unconventional uses of dispose -- to insure S doesn't behaves as expected in these cases 32 | 33 | test('works from the body of its own computation', function(t) { 34 | root(function(dispose) { 35 | var c = 0, 36 | d = o(0), 37 | f = S(function() { 38 | c++; 39 | if (d()) dispose(); 40 | d(); 41 | }); 42 | 43 | t.equal(c, 1); 44 | 45 | d(1); 46 | t.equal(c, 2); 47 | 48 | d(2); 49 | t.equal(c, 2); 50 | 51 | t.end(); 52 | }); 53 | }); 54 | 55 | test('works from the body of a subcomputation', function(t) { 56 | root(function(dispose) { 57 | var c = 0, 58 | d = o(0), 59 | f = S(function() { 60 | c++; 61 | d(); 62 | S(function() { 63 | if (d()) dispose(); 64 | }); 65 | }); 66 | 67 | t.equal(c, 1); 68 | 69 | d(1); 70 | 71 | t.equal(c, 2); 72 | 73 | d(2); 74 | 75 | t.equal(c, 2); 76 | t.end(); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /test/observable/observable.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import spy from 'ispy'; 3 | import { 4 | o, 5 | subscribe, 6 | unsubscribe, 7 | cleanup, 8 | isListening 9 | } from 'sinuous/observable'; 10 | 11 | test('initial value can be set', function(t) { 12 | let title = o('Groovy!'); 13 | t.equal(title(), 'Groovy!'); 14 | t.end(); 15 | }); 16 | 17 | test('runs function on subscribe', function(t) { 18 | subscribe(t.pass); 19 | t.end(); 20 | }); 21 | 22 | test('observable can be set without subscription', function(t) { 23 | let title = o(); 24 | title('Groovy!'); 25 | t.equal(title(), 'Groovy!'); 26 | t.end(); 27 | }); 28 | 29 | test('isListening', function(t) { 30 | let title = o(); 31 | t.assert(!isListening()); 32 | subscribe(() => { 33 | title(); 34 | t.assert(isListening()); 35 | }); 36 | t.end(); 37 | }); 38 | 39 | test('updates when the observable is set', function(t) { 40 | let title = o(); 41 | let text; 42 | subscribe(() => (text = title())); 43 | 44 | title('Welcome to Sinuous!'); 45 | t.equal(text, 'Welcome to Sinuous!'); 46 | 47 | title('Groovy!'); 48 | t.equal(text, 'Groovy!'); 49 | 50 | t.end(); 51 | }); 52 | 53 | test('observable unsubscribe', function(t) { 54 | let title = o('Initial title'); 55 | let text; 56 | const unsubscribe = subscribe(() => (text = title())); 57 | 58 | title('Welcome to Sinuous!'); 59 | t.equal(text, 'Welcome to Sinuous!'); 60 | 61 | unsubscribe(); 62 | 63 | title('Groovy!'); 64 | t.equal(text, 'Welcome to Sinuous!'); 65 | 66 | t.end(); 67 | }); 68 | 69 | test('nested subscribe', function(t) { 70 | let apple = o('apple'); 71 | let lemon = o('lemon'); 72 | let onion = o('onion'); 73 | let tempApple; 74 | let tempLemon; 75 | let tempOnion; 76 | 77 | let veggieSpy; 78 | const fruitSpy = spy(); 79 | fruitSpy.delegate = () => { 80 | tempApple = apple(); 81 | 82 | veggieSpy = spy(); 83 | veggieSpy.delegate = () => { 84 | tempOnion = onion(); 85 | }; 86 | 87 | subscribe(veggieSpy); 88 | 89 | tempLemon = lemon(); 90 | }; 91 | 92 | subscribe(fruitSpy); 93 | 94 | t.equal(tempApple, 'apple'); 95 | t.equal(tempLemon, 'lemon'); 96 | t.equal(tempOnion, 'onion'); 97 | t.equal(fruitSpy.callCount, 1); 98 | t.equal(veggieSpy.callCount, 1); 99 | 100 | onion('peel'); 101 | t.equal(tempOnion, 'peel'); 102 | t.equal(fruitSpy.callCount, 1); 103 | t.equal(veggieSpy.callCount, 2); 104 | 105 | lemon('juice'); 106 | t.equal(tempLemon, 'juice'); 107 | t.equal(fruitSpy.callCount, 2); 108 | // this will be a new spy that was executed once 109 | t.equal(veggieSpy.callCount, 1); 110 | 111 | t.end(); 112 | }); 113 | 114 | test('one level nested subscribe cleans up inner subscriptions', function(t) { 115 | let apple = o('apple'); 116 | let lemon = o('lemon'); 117 | let grape = o('grape'); 118 | let onion = o('onion'); 119 | let bean = o('bean'); 120 | let carrot = o('carrot'); 121 | let onions = ''; 122 | let beans = ''; 123 | let carrots = ''; 124 | 125 | subscribe(() => { 126 | apple(); 127 | subscribe(() => (onions += onion())); 128 | grape(); 129 | subscribe(() => (beans += bean())); 130 | subscribe(() => (carrots += carrot())); 131 | lemon(); 132 | }); 133 | 134 | apple('juice'); 135 | lemon('juice'); 136 | grape('juice'); 137 | 138 | bean('bean'); 139 | 140 | t.equal(onions, 'onion'.repeat(4)); 141 | t.equal(beans, 'bean'.repeat(5)); 142 | t.end(); 143 | }); 144 | 145 | test('three level nested subscribe cleans up inner subscriptions', function(t) { 146 | let apple = o('apple'); 147 | let lemon = o('lemon'); 148 | let grape = o('grape'); 149 | let onion = o('onion'); 150 | let bean = o('bean'); 151 | let carrot = o('carrot'); 152 | let peanut = o('peanut'); 153 | let onions = 0; 154 | let beans = 0; 155 | let carrots = 0; 156 | let peanuts = 0; 157 | 158 | const unsubscribe = subscribe(() => { 159 | apple(); 160 | subscribe(() => { 161 | bean(); 162 | beans += 1; 163 | subscribe(() => { 164 | onions += 1; 165 | onion(); 166 | subscribe(() => peanut() && (peanuts += 1)); 167 | }); 168 | }); 169 | grape(); 170 | subscribe(() => carrot() && (carrots += 1)); 171 | lemon(); 172 | }); 173 | 174 | apple('juice'); 175 | lemon('juice'); 176 | grape('juice'); 177 | t.equal(beans, 4); 178 | 179 | bean('bean'); 180 | t.equal(beans, 5); 181 | 182 | onion('onion'); 183 | onion('onion'); 184 | onion('onion'); 185 | t.equal(onions, 8); 186 | 187 | peanut('peanut'); 188 | peanut('peanut'); 189 | t.equal(peanuts, 10); 190 | 191 | unsubscribe(); 192 | 193 | apple('juice'); 194 | lemon('juice'); 195 | grape('juice'); 196 | 197 | bean('bean'); 198 | t.equal(beans, 5); 199 | 200 | onion('onion'); 201 | onion('onion'); 202 | onion('onion'); 203 | t.equal(onions, 8); 204 | 205 | peanut('peanut'); 206 | peanut('peanut'); 207 | t.equal(peanuts, 10); 208 | 209 | t.end(); 210 | }); 211 | 212 | test('standalone unsubscribe works', function(t) { 213 | let carrot = o(); 214 | const computed = spy(); 215 | computed.delegate = () => { 216 | carrot(); 217 | }; 218 | subscribe(computed); 219 | carrot('juice'); 220 | 221 | unsubscribe(computed); 222 | carrot('juice'); 223 | 224 | t.equal(computed.callCount, 2); 225 | t.end(); 226 | }); 227 | 228 | test('cleanup cleans up on update', function(t) { 229 | let carrot = o(); 230 | let button = document.createElement('button'); 231 | // IE11 requires the button to be in dom before `button.click()` works. 232 | document.body.appendChild(button); 233 | let count = 0; 234 | 235 | const computed = spy(); 236 | computed.delegate = () => { 237 | carrot(); 238 | const onClick = () => (count += 1); 239 | button.addEventListener('click', onClick); 240 | }; 241 | 242 | const unsubscribe = subscribe(computed); 243 | carrot(9); 244 | carrot(10); 245 | button.click(); 246 | t.equal(count, 3); 247 | unsubscribe(); 248 | 249 | count = 0; 250 | button = document.createElement('button'); 251 | document.body.appendChild(button); 252 | 253 | const computedWithCleanup = spy(); 254 | computedWithCleanup.delegate = () => { 255 | carrot(); 256 | const onClick = () => (count += 1); 257 | button.addEventListener('click', onClick); 258 | cleanup(() => button.removeEventListener('click', onClick)); 259 | }; 260 | 261 | subscribe(computedWithCleanup); 262 | carrot(9); 263 | carrot(10); 264 | button.click(); 265 | t.equal(count, 1); 266 | 267 | t.end(); 268 | }); 269 | -------------------------------------------------------------------------------- /test/observable/on.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import spy from 'ispy'; 3 | import { o, root, on } from 'sinuous/observable'; 4 | 5 | test('registers a dependency', function(t) { 6 | root(function() { 7 | var d = o(1), 8 | callSpy = spy(), 9 | f = on(d, function() { 10 | callSpy(); 11 | }); 12 | 13 | t.equal(callSpy.callCount, 1); 14 | 15 | d(2); 16 | 17 | t.equal(callSpy.callCount, 2); 18 | }); 19 | t.end(); 20 | }); 21 | 22 | test('prohibits dynamic dependencies', function(t) { 23 | root(function() { 24 | var d = o(1), 25 | callSpy = spy(), 26 | s = on( 27 | function() {}, 28 | function() { 29 | callSpy(); 30 | return d(); 31 | } 32 | ); 33 | 34 | t.equal(callSpy.callCount, 1); 35 | 36 | d(2); 37 | 38 | t.equal(callSpy.callCount, 1); 39 | }); 40 | t.end(); 41 | }); 42 | 43 | test('allows multiple dependencies', function(t) { 44 | root(function() { 45 | var a = o(1), 46 | b = o(2), 47 | c = o(3), 48 | callSpy = spy(), 49 | f = on( 50 | function() { 51 | a(); 52 | b(); 53 | c(); 54 | }, 55 | function() { 56 | callSpy(); 57 | } 58 | ); 59 | 60 | t.equal(callSpy.callCount, 1); 61 | 62 | a(4); 63 | b(5); 64 | c(6); 65 | 66 | t.equal(callSpy.callCount, 4); 67 | }); 68 | t.end(); 69 | }); 70 | 71 | test('allows an array of dependencies', function(t) { 72 | root(function() { 73 | var a = o(1), 74 | b = o(2), 75 | c = o(3), 76 | callSpy = spy(), 77 | f = on([a, b, c], function() { 78 | callSpy(); 79 | }); 80 | 81 | t.equal(callSpy.callCount, 1); 82 | 83 | a(4); 84 | b(5); 85 | c(6); 86 | 87 | t.equal(callSpy.callCount, 4); 88 | }); 89 | t.end(); 90 | }); 91 | 92 | test('modifies its accumulator when reducing', function(t) { 93 | root(function() { 94 | var a = o(1), 95 | c = on( 96 | a, 97 | function(sum) { 98 | return sum + a(); 99 | }, 100 | 0 101 | ); 102 | 103 | t.equal(c(), 1); 104 | 105 | a(2); 106 | 107 | t.equal(c(), 3); 108 | 109 | a(3); 110 | a(4); 111 | 112 | t.equal(c(), 10); 113 | }); 114 | t.end(); 115 | }); 116 | 117 | test('suppresses initial run when onchanges is true', function(t) { 118 | root(function() { 119 | var a = o(1), 120 | c = on( 121 | a, 122 | function() { 123 | return a() * 2; 124 | }, 125 | 0, 126 | true 127 | ); 128 | 129 | t.equal(c(), 0); 130 | 131 | a(2); 132 | 133 | t.equal(c(), 4); 134 | }); 135 | t.end(); 136 | }); 137 | -------------------------------------------------------------------------------- /test/observable/perf/index.js: -------------------------------------------------------------------------------- 1 | const start = Date.now(); 2 | 3 | if (process.env.PERSIST) { 4 | const fs = require('fs'); 5 | const logFile = __dirname + '/perf.txt'; 6 | // clear previous results 7 | if (fs.existsSync(logFile)) fs.unlinkSync(logFile); 8 | 9 | exports.logMeasurement = function(msg) { 10 | console.log(msg); 11 | fs.appendFileSync(logFile, '\n' + msg, 'utf8'); 12 | }; 13 | } else { 14 | exports.logMeasurement = function(msg) { 15 | console.log(msg); 16 | }; 17 | } 18 | 19 | require('./perf.js'); 20 | 21 | // This test runs last.. 22 | require('tape')(t => { 23 | exports.logMeasurement( 24 | '\n\nCompleted performance suite in ' + 25 | (Date.now() - start) / 1000 + 26 | ' sec.' 27 | ); 28 | t.end(); 29 | }); 30 | -------------------------------------------------------------------------------- /test/observable/perf/lib/reactive.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var defaultContext = this; 3 | var __id = 0; 4 | 5 | function $R(fnc, context) { 6 | var rf = function() { 7 | var dirtyNodes = topo(rf); 8 | var v = dirtyNodes[0].run.apply(rf, arguments); 9 | dirtyNodes.slice(1).forEach(function (n) { n.run(); } ); 10 | return v; 11 | }; 12 | rf.id = __id++; 13 | rf.context = context || defaultContext; 14 | rf.fnc = fnc; 15 | rf.dependents = []; 16 | rf.dependencies = []; 17 | rf.memo = $R.empty; 18 | return $R.extend(rf, reactiveExtensions, $R.pluginExtensions); 19 | } 20 | $R.version = "1.0.0"; 21 | $R._ = {}; 22 | $R.empty = {}; 23 | $R.state = function (initial) { 24 | var rFnc = $R(function () { 25 | return this.val; 26 | }); 27 | rFnc.context = rFnc; 28 | rFnc.val = initial; 29 | rFnc.set = $R(function(value) { this.val = value; return this(); }.bind(rFnc)); 30 | rFnc.modify = $R(function(transform) { return this.set(transform(this.val)); }.bind(rFnc)); 31 | return rFnc; 32 | }; 33 | $R.extend = function(o) { 34 | var extensions = Array.prototype.slice.call(arguments, 1); 35 | extensions.forEach(function (extension) { 36 | if (extension) { 37 | for (var prop in extension) { o[prop] = extension[prop]; } 38 | } 39 | }); 40 | return o; 41 | }; 42 | $R.pluginExtensions = {}; 43 | 44 | var reactiveExtensions = { 45 | _isReactive: true, 46 | toString: function () { return this.fnc.toString(); }, 47 | get: function() { return this.memo === $R.empty ? this.run() : this.memo; }, 48 | run: function() { 49 | var unboundArgs = Array.prototype.slice.call(arguments); 50 | return this.memo = this.fnc.apply(this.context, this.argumentList(unboundArgs)); 51 | }, 52 | bindTo: function() { 53 | var newDependencies = Array.prototype.slice.call(arguments).map(wrap); 54 | var oldDependencies = this.dependencies; 55 | 56 | oldDependencies.forEach(function (d) { 57 | if (d !== $R._) { d.removeDependent(this); } 58 | }, this); 59 | 60 | newDependencies.forEach(function (d) { 61 | if (d !== $R._) { d.addDependent(this); } 62 | }, this); 63 | 64 | this.dependencies = newDependencies; 65 | return this; 66 | }, 67 | removeDependent: function(rFnc) { 68 | this.dependents = this.dependents.filter(function (d) { return d !== rFnc; }); 69 | }, 70 | addDependent: function(rFnc) { 71 | if (!this.dependents.some(function (d) { return d === rFnc; })) { 72 | this.dependents.push(rFnc); 73 | } 74 | }, 75 | argumentList: function(unboundArgs) { 76 | return this.dependencies.map(function(dependency) { 77 | if (dependency === $R._) { 78 | return unboundArgs.shift(); 79 | } else if (dependency._isReactive) { 80 | return dependency.get(); 81 | } else { 82 | return undefined; 83 | } 84 | }).concat(unboundArgs); 85 | } 86 | }; 87 | 88 | if (typeof module !== 'undefined') { 89 | module.exports = $R; 90 | } else { 91 | defaultContext.$R = $R; 92 | } 93 | 94 | //Private 95 | function topo(rootFnc) { 96 | var explored = {}; 97 | function search(rFnc) { 98 | if (explored[rFnc.id]) { return []; } 99 | explored[rFnc.id] = true; 100 | return rFnc.dependents.reduce(function (acc, dep) { return acc.concat(search(dep))},[]).concat(rFnc); 101 | } 102 | 103 | return search(rootFnc).reverse(); 104 | } 105 | 106 | function wrap(v) { 107 | return v && (v._isReactive || v == $R._) ? v : $R(function () {return v;}); 108 | } 109 | })(); -------------------------------------------------------------------------------- /test/observable/perf/lib/reactor.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.7.1 2 | (function() { 3 | var CompoundError, OBSERVER, SIGNAL, dependencyStack, global, 4 | __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }, 5 | __hasProp = {}.hasOwnProperty, 6 | __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; 7 | 8 | SIGNAL = "SIGNAL"; 9 | 10 | OBSERVER = "OBSERVER"; 11 | 12 | global = typeof exports !== "undefined" && exports !== null ? exports : this; 13 | 14 | dependencyStack = []; 15 | 16 | global.Signal = function(definition) { 17 | var signalCore, signalInterface; 18 | signalCore = { 19 | dependencyType: SIGNAL, 20 | definition: null, 21 | value: null, 22 | error: null, 23 | dependents: [], 24 | dependencies: [], 25 | update: function() { 26 | var dependency, dependentIndex, error, _i, _len, _ref; 27 | _ref = this.dependencies; 28 | for (_i = 0, _len = _ref.length; _i < _len; _i++) { 29 | dependency = _ref[_i]; 30 | dependentIndex = dependency.dependents.indexOf(this); 31 | dependency.dependents.splice(dependentIndex, 1); 32 | } 33 | this.dependencies = []; 34 | this.error = null; 35 | if (this.definition instanceof Function) { 36 | dependencyStack.push(this); 37 | try { 38 | return this.value = this.definition(); 39 | } catch (_error) { 40 | error = _error; 41 | this.error = error; 42 | throw error; 43 | } finally { 44 | dependencyStack.pop(); 45 | } 46 | } else { 47 | return this.value = this.definition; 48 | } 49 | }, 50 | read: function() { 51 | var dependent, signalError; 52 | dependent = dependencyStack[dependencyStack.length - 1]; 53 | if (dependent != null) { 54 | if (__indexOf.call(this.dependents, dependent) < 0) { 55 | this.dependents.push(dependent); 56 | } 57 | if (__indexOf.call(dependent.dependencies, this) < 0) { 58 | dependent.dependencies.push(this); 59 | } 60 | } 61 | if (this.error) { 62 | signalError = new Error('Reading from corrupted Signal'); 63 | throw signalError; 64 | } else { 65 | return this.value; 66 | } 67 | }, 68 | write: function(newDefinition) { 69 | var dependencyQueue, dependent, error, errorList, errorMessage, observer, observerList, target, _i, _j, _len, _len1, _ref; 70 | this.definition = newDefinition; 71 | dependencyQueue = [this]; 72 | observerList = []; 73 | errorList = []; 74 | while (dependencyQueue.length >= 1) { 75 | target = dependencyQueue.shift(); 76 | try { 77 | target.update(); 78 | } catch (_error) { 79 | error = _error; 80 | errorList.push(error); 81 | } 82 | _ref = target.dependents; 83 | for (_i = 0, _len = _ref.length; _i < _len; _i++) { 84 | dependent = _ref[_i]; 85 | if (dependent.dependencyType === SIGNAL) { 86 | if (__indexOf.call(dependencyQueue, dependent) < 0) { 87 | dependencyQueue.push(dependent); 88 | } 89 | } else if (dependent.dependencyType === OBSERVER) { 90 | if (__indexOf.call(observerList, dependent) < 0) { 91 | observerList.push(dependent); 92 | } 93 | } 94 | } 95 | } 96 | for (_j = 0, _len1 = observerList.length; _j < _len1; _j++) { 97 | observer = observerList[_j]; 98 | try { 99 | observer.update(); 100 | } catch (_error) { 101 | error = _error; 102 | errorList.push(error); 103 | } 104 | } 105 | if (errorList.length === 1) { 106 | throw errorList[0]; 107 | } else if (errorList.length > 1) { 108 | errorMessage = errorList.length + " errors due to signal write"; 109 | throw new CompoundError(errorMessage, errorList); 110 | } 111 | return this.value; 112 | } 113 | }; 114 | signalInterface = function(newDefinition) { 115 | if (arguments.length === 0) { 116 | return signalCore.read(); 117 | } else { 118 | if (newDefinition instanceof Object) { 119 | signalInterface.set = function(key, value) { 120 | var output; 121 | output = newDefinition[key] = value; 122 | signalCore.write(newDefinition); 123 | return output; 124 | }; 125 | } else { 126 | delete signalInterface.set; 127 | } 128 | if (newDefinition instanceof Array) { 129 | signalInterface.push = function() { 130 | var output; 131 | output = newDefinition.push.apply(newDefinition, arguments); 132 | signalCore.write(newDefinition); 133 | return output; 134 | }; 135 | signalInterface.pop = function() { 136 | var output; 137 | output = newDefinition.pop.apply(newDefinition, arguments); 138 | signalCore.write(newDefinition); 139 | return output; 140 | }; 141 | signalInterface.shift = function() { 142 | var output; 143 | output = newDefinition.shift.apply(newDefinition, arguments); 144 | signalCore.write(newDefinition); 145 | return output; 146 | }; 147 | signalInterface.unshift = function() { 148 | var output; 149 | output = newDefinition.unshift.apply(newDefinition, arguments); 150 | signalCore.write(newDefinition); 151 | return output; 152 | }; 153 | signalInterface.reverse = function() { 154 | var output; 155 | output = newDefinition.reverse.apply(newDefinition, arguments); 156 | signalCore.write(newDefinition); 157 | return output; 158 | }; 159 | signalInterface.sort = function() { 160 | var output; 161 | output = newDefinition.sort.apply(newDefinition, arguments); 162 | signalCore.write(newDefinition); 163 | return output; 164 | }; 165 | signalInterface.splice = function() { 166 | var output; 167 | output = newDefinition.splice.apply(newDefinition, arguments); 168 | signalCore.write(newDefinition); 169 | return output; 170 | }; 171 | } else { 172 | delete signalInterface.push; 173 | delete signalInterface.pop; 174 | delete signalInterface.shift; 175 | delete signalInterface.unshift; 176 | delete signalInterface.reverse; 177 | delete signalInterface.sort; 178 | delete signalInterface.splice; 179 | } 180 | return signalCore.write(newDefinition); 181 | } 182 | }; 183 | signalInterface(definition); 184 | return signalInterface; 185 | }; 186 | 187 | global.Observer = function(definition) { 188 | var observerCore, observerInterface; 189 | observerCore = { 190 | dependencyType: OBSERVER, 191 | definition: null, 192 | dependencies: [], 193 | update: function() { 194 | var dependency, dependentIndex, _i, _len, _ref; 195 | _ref = this.dependencies; 196 | for (_i = 0, _len = _ref.length; _i < _len; _i++) { 197 | dependency = _ref[_i]; 198 | dependentIndex = dependency.dependents.indexOf(this); 199 | dependency.dependents.splice(dependentIndex, 1); 200 | } 201 | this.dependencies = []; 202 | if (definition instanceof Function) { 203 | dependencyStack.push(this); 204 | try { 205 | return this.definition(); 206 | } finally { 207 | dependencyStack.pop(); 208 | } 209 | } 210 | }, 211 | write: function(newdefinition) { 212 | this.definition = newdefinition; 213 | return this.update(); 214 | } 215 | }; 216 | observerInterface = function(newdefinition) { 217 | return write(newdefinition); 218 | }; 219 | observerCore.write(definition); 220 | return observerInterface; 221 | }; 222 | 223 | CompoundError = (function(_super) { 224 | __extends(CompoundError, _super); 225 | 226 | function CompoundError(message, errorArray) { 227 | var error, errorDescription, errorProperties, property, proxyError, _i, _j, _len, _len1, _ref, _ref1; 228 | this.errors = errorArray; 229 | _ref = this.errors; 230 | for (_i = 0, _len = _ref.length; _i < _len; _i++) { 231 | error = _ref[_i]; 232 | errorDescription = (_ref1 = error.stack) != null ? _ref1 : error.toString(); 233 | message = message + '\n' + errorDescription; 234 | } 235 | proxyError = Error.call(this, message); 236 | proxyError.name = "CompoundError"; 237 | errorProperties = Object.getOwnPropertyNames(proxyError); 238 | for (_j = 0, _len1 = errorProperties.length; _j < _len1; _j++) { 239 | property = errorProperties[_j]; 240 | if (proxyError.hasOwnProperty(property)) { 241 | this[property] = proxyError[property]; 242 | } 243 | } 244 | return this; 245 | } 246 | 247 | return CompoundError; 248 | 249 | })(Error); 250 | 251 | global.CompoundError = CompoundError; 252 | 253 | }).call(this); -------------------------------------------------------------------------------- /test/observable/root.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { o, S, root } from 'sinuous/observable'; 3 | 4 | test('allows subcomputations to escape their parents', function(t) { 5 | root(function() { 6 | var outerTrigger = o(null), 7 | innerTrigger = o(null), 8 | innerRuns = 0; 9 | 10 | S(function() { 11 | // register dependency to outer trigger 12 | outerTrigger(); 13 | // inner computation 14 | root(function() { 15 | S(function() { 16 | // register dependency on inner trigger 17 | innerTrigger(); 18 | // count total runs 19 | innerRuns++; 20 | }); 21 | }); 22 | }); 23 | 24 | // at start, we have one inner computation, that's run once 25 | t.equal(innerRuns, 1); 26 | 27 | // trigger the outer computation, making more inners 28 | outerTrigger(null); 29 | outerTrigger(null); 30 | 31 | t.equal(innerRuns, 3); 32 | 33 | // now trigger inner signal: three orphaned computations should equal three runs 34 | innerRuns = 0; 35 | innerTrigger(null); 36 | 37 | t.equal(innerRuns, 3); 38 | t.end(); 39 | }); 40 | }); 41 | 42 | //test("is necessary to create a toplevel computation", function () { 43 | // t.equal(() => { 44 | // S(() => 1) 45 | // }).toThrowError(/root/); 46 | //}); 47 | 48 | test('does not freeze updates when used at top level', function(t) { 49 | root(() => { 50 | var s = o(1); 51 | var c = S(() => s()); 52 | 53 | t.equal(c(), 1); 54 | s(2); 55 | t.equal(c(), 2); 56 | s(3); 57 | t.equal(c(), 3); 58 | t.end(); 59 | }); 60 | }); 61 | 62 | test('persists through entire scope when used at top level', t => { 63 | root(() => { 64 | var s = o(1); 65 | 66 | S(() => s()); 67 | s(2); 68 | 69 | var c2 = S(() => s()); 70 | s(3); 71 | 72 | t.equal(c2(), 3); 73 | t.end(); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /test/observable/sample.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { o, S, sample } from 'sinuous/observable'; 3 | 4 | test('avoids a depdendency', function(t) { 5 | var a = o(1), 6 | b = o(2), 7 | c = o(3), 8 | d = 0; 9 | 10 | S(function() { 11 | d++; 12 | a(); 13 | sample(b); 14 | c(); 15 | }); 16 | 17 | t.equal(d, 1); 18 | 19 | b(4); 20 | 21 | t.equal(d, 1); 22 | 23 | a(5); 24 | c(6); 25 | 26 | t.equal(d, 3); 27 | t.end(); 28 | }); 29 | -------------------------------------------------------------------------------- /test/observable/transaction.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { o, S, root, transaction } from 'sinuous/observable'; 3 | 4 | test('batches all changes until end', function(t) { 5 | var d1 = o(9); 6 | var d2 = o(99); 7 | 8 | transaction(function() { 9 | d1(10); 10 | d2(100); 11 | t.equal(d1(), 9); 12 | t.equal(d2(), 99); 13 | }); 14 | 15 | t.equal(d1(), 10); 16 | t.equal(d2(), 100); 17 | t.end(); 18 | }); 19 | 20 | test('halts propagation within its scope', function(t) { 21 | root(function() { 22 | var d1 = o(9); 23 | var d2 = o(99); 24 | 25 | var f = S(function() { 26 | return d1() + d2(); 27 | }); 28 | 29 | transaction(function() { 30 | d1(10); 31 | d2(100); 32 | 33 | t.equal(f(), 9 + 99); 34 | }); 35 | 36 | t.equal(f(), 10 + 100); 37 | t.end(); 38 | }); 39 | }); 40 | 41 | test('nested transaction', function(t) { 42 | var d = o(1); 43 | 44 | transaction(function() { 45 | d(2); 46 | t.equal(d(), 1); 47 | 48 | transaction(function() { 49 | d(3); 50 | t.equal(d(), 1); 51 | 52 | transaction(function() { 53 | d(4); 54 | }); 55 | 56 | t.equal(d(), 1); 57 | }); 58 | 59 | t.equal(d(), 1); 60 | }); 61 | 62 | t.equal(d(), 4); 63 | t.end(); 64 | }); 65 | -------------------------------------------------------------------------------- /test/observable/value.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { o, S, root } from 'sinuous/observable'; 3 | 4 | 5 | function value(current, eq) { 6 | const v = o(current); 7 | return function(update) { 8 | if (!arguments.length) return v(); 9 | if (!(eq ? eq(update, current) : update === current)) { 10 | current = v(update); 11 | } 12 | return update; 13 | }; 14 | } 15 | 16 | test("takes and returns an initial value", function (t) { 17 | t.equal(value(1)(), 1); 18 | t.end(); 19 | }); 20 | 21 | test("can be set by passing in a new value", function (t) { 22 | var d = value(1); 23 | d(2); 24 | t.equal(d(), 2); 25 | t.end(); 26 | }); 27 | 28 | test("returns value being set", function (t) { 29 | var d = value(1); 30 | t.equal(d(2), 2); 31 | t.end(); 32 | }); 33 | 34 | test("does not propagate if set to equal value", function (t) { 35 | root(function () { 36 | var d = value(1), 37 | e = 0, 38 | f = S(function () { d(); return ++e; }); 39 | 40 | t.equal(f(), 1); 41 | d(1); 42 | t.equal(f(), 1); 43 | }); 44 | t.end(); 45 | }); 46 | 47 | test("propagate if set to unequal value", function (t) { 48 | root(function () { 49 | var d = value(1), 50 | e = 0, 51 | f = S(function () { d(); return ++e; }); 52 | 53 | t.equal(f(), 1); 54 | d(1); 55 | t.equal(f(), 1); 56 | d(2); 57 | t.equal(f(), 2); 58 | }); 59 | t.end(); 60 | }); 61 | 62 | test("can take an equality predicate", function (t) { 63 | root(function () { 64 | var d = value([1], function (a, b) { return a[0] === b[0]; }), 65 | e = 0, 66 | f = S(function () { d(); return ++e; }); 67 | 68 | t.equal(f(), 1); 69 | d([1]); 70 | t.equal(f(), 1); 71 | d([2]); 72 | t.equal(f(), 2); 73 | }); 74 | t.end(); 75 | }); 76 | -------------------------------------------------------------------------------- /test/sinuous.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { o, html } from 'sinuous'; 3 | import { subscribe } from 'sinuous/observable'; 4 | import { map } from 'sinuous/map'; 5 | import { fragInnerHTML } from './_utils.js'; 6 | 7 | test('simple', function(t) { 8 | t.equal( 9 | html` 10 |

    11 | `.outerHTML, 12 | '

    ' 13 | ); 14 | t.equal( 15 | html` 16 |

    hello world

    17 | `.outerHTML, 18 | '

    hello world

    ' 19 | ); 20 | t.end(); 21 | }); 22 | 23 | test('returns a simple string', t => { 24 | const frag = html` 25 | a 26 | `; 27 | t.assert(frag instanceof DocumentFragment); 28 | t.assert(frag.childNodes[0] instanceof Text); 29 | t.equal(frag.childNodes[0].textContent, 'a'); 30 | t.end(); 31 | }); 32 | 33 | test('returns a simple number', t => { 34 | const frag = html` 35 | ${9} 36 | `; 37 | t.assert(frag instanceof DocumentFragment); 38 | t.assert(frag.childNodes[0] instanceof Text); 39 | t.equal(frag.childNodes[0].textContent, '9'); 40 | t.end(); 41 | }); 42 | 43 | test('returns a document fragment', t => { 44 | const frag = html` 45 | ${[ 46 | html` 47 |
    Banana
    48 | `, 49 | html` 50 |
    Apple
    51 | ` 52 | ]} 53 | `; 54 | t.assert(frag instanceof DocumentFragment); 55 | t.equal(frag.childNodes[0].outerHTML, '
    Banana
    '); 56 | t.equal(frag.childNodes[1].outerHTML, '
    Apple
    '); 57 | t.end(); 58 | }); 59 | 60 | test('returns a simple observable string', t => { 61 | const title = o('Banana'); 62 | const frag = html` 63 | ${title} 64 | `; 65 | t.assert(frag instanceof DocumentFragment); 66 | t.assert(frag.childNodes[0] instanceof Text); 67 | t.equal(frag.childNodes[0].textContent, 'Banana'); 68 | t.end(); 69 | }); 70 | 71 | test('component children order', t => { 72 | let order = ''; 73 | const Comp = (props, ...children) => { 74 | order += 'a'; 75 | return children; 76 | }; 77 | const Child = () => { 78 | order += 'b'; 79 | return html``; 80 | }; 81 | 82 | const result = html` 83 | <${Comp}> 84 | <${Child} /> 85 | 86 | `; 87 | 88 | t.equal(order, 'ab'); 89 | t.equal(fragInnerHTML(result), ''); 90 | t.end(); 91 | }); 92 | 93 | test('conditional lists without root', t => { 94 | const choice = o(1); 95 | const filler = o(0); 96 | 97 | const Spinner = () => html`
    `; 98 | 99 | const Story = (index) => { 100 | const n1 = `a${index}`; 101 | const n2 = `b${index}`; 102 | const list = o(); 103 | 104 | subscribe(() => { 105 | if (filler() === index) list([n1, n2]); 106 | }); 107 | 108 | return html`${() => list() ? map(list, (item) => html`${item}`) : Spinner()}`; 109 | }; 110 | 111 | const log = (el, ...args) => { 112 | console.warn(Array.from(el.childNodes) 113 | .map(c => `${c}${c.__g?','+c.__g:''}`).join(' — '), ...args); 114 | console.warn(''); 115 | }; 116 | 117 | const firstStory = Story(1); 118 | 119 | console.warn('raw story 1 element'); 120 | log(firstStory); 121 | const stories = [firstStory, Story(2), Story(3)]; 122 | 123 | const div = html`
    ${() => stories[choice() - 1]}
    `; 124 | document.body.appendChild(div); 125 | log(div); 126 | 127 | console.warn('story 1 - filler 1'); 128 | 129 | filler(1); 130 | t.equal(div.children.length, 2); 131 | log(div); 132 | 133 | console.warn('story 2 - filler 2'); 134 | choice(2); 135 | t.equal(div.children.length, 1); 136 | log(div); 137 | 138 | filler(2); 139 | t.equal(div.children[0].innerText, 'a2'); 140 | t.equal(div.children.length, 2); 141 | 142 | t.end(); 143 | }); 144 | 145 | test('nested fragments without root', t => { 146 | const choice = o(0); 147 | const show = o(true); 148 | const show2 = o(true); 149 | 150 | const Story = (index) => { 151 | const n1 = `a${index}`; 152 | const n2 = `b${index}`; 153 | const list = o([n1, n2]); 154 | return html`${() => show() ? map(list, (item) => html`${item}`) : ''}`; 155 | }; 156 | 157 | const firstStory = Story(1); 158 | const stories = [firstStory, Story(2), Story(3)]; 159 | 160 | const div = html`
    ${() => show2() && stories[choice()]}
    `; 161 | document.body.appendChild(div); 162 | 163 | 164 | t.equal(div.children.length, 2); 165 | t.equal(div.children[0].innerText, 'a1'); 166 | 167 | show(false); 168 | t.equal(div.children.length, 0); 169 | 170 | show(true); 171 | choice(1); 172 | t.equal(div.children[0].innerText, 'a2'); 173 | 174 | t.equal(div.children.length, 2); 175 | 176 | show(false); 177 | t.equal(div.children.length, 0); 178 | 179 | show(true); 180 | choice(2); 181 | 182 | t.equal(div.children.length, 2); 183 | 184 | show2(false); 185 | t.equal(div.children.length, 0); 186 | 187 | choice(1); 188 | show(true); 189 | show2(true); 190 | 191 | t.equal(div.children.length, 2); 192 | t.equal(div.children[0].innerText, 'a2'); 193 | 194 | t.end(); 195 | }); 196 | -------------------------------------------------------------------------------- /test/template.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import spy from 'ispy'; 3 | import { h, html } from 'sinuous'; 4 | import { template, o, t } from 'sinuous/template'; 5 | import { map } from 'sinuous/map'; 6 | import { normalizeAttributes } from './_utils.js'; 7 | 8 | test('tags return functions', function(tt) { 9 | tt.assert(typeof o() === 'function'); 10 | tt.assert(typeof t() === 'function'); 11 | tt.end(); 12 | }); 13 | 14 | test('template returns a function', function(tt) { 15 | tt.assert(typeof template(() => h('h1')) === 'function'); 16 | tt.end(); 17 | }); 18 | 19 | test('template result returns an element', function(tt) { 20 | tt.equal(template(() => h('h1'))().firstChild.outerHTML, '

    '); 21 | tt.end(); 22 | }); 23 | 24 | test('template result fills tags', function(tt) { 25 | tt.equal( 26 | template(() => h('h1', t('title')))({ title: 'Test' }).firstChild.outerHTML, 27 | '

    Test

    ' 28 | ); 29 | tt.end(); 30 | }); 31 | 32 | test('template works w/ event listeners', function(tt) { 33 | const buttonClick = spy(); 34 | const obj = { buttonClick }; 35 | const btn = template(() => 36 | h('button', { onclick: o('buttonClick') }, 'Click me') 37 | )(obj).firstChild; 38 | 39 | btn.click(); 40 | tt.equal(buttonClick.callCount, 1, 'click called'); 41 | 42 | obj.buttonClick = spy(); 43 | btn.click(); 44 | tt.equal(obj.buttonClick.callCount, 1, 'can change click handler via observable prop'); 45 | 46 | tt.equal(buttonClick.callCount, 1, 'first handler is still clicked just once'); 47 | 48 | tt.end(); 49 | }); 50 | 51 | test('template result fills observable tags', function(tt) { 52 | const obj = { title: 'Apple', class: 'juice' }; 53 | const tmpl = template(() => 54 | h('h1', h('span', { class: o('class') }, 'Pear'), h('span', o('title'))) 55 | )(obj); 56 | 57 | tt.equal( 58 | tmpl.firstChild.children[0].outerHTML, 59 | 'Pear' 60 | ); 61 | tt.equal(tmpl.firstChild.children[1].outerHTML, 'Apple'); 62 | 63 | obj.title = '⛄️'; 64 | obj.class = 'mousse'; 65 | 66 | tt.equal(obj.title, '⛄️'); 67 | tt.equal( 68 | tmpl.firstChild.children[0].outerHTML, 69 | 'Pear' 70 | ); 71 | tt.equal(tmpl.firstChild.children[1].outerHTML, '⛄️'); 72 | tt.end(); 73 | }); 74 | 75 | test('template result fills tags w/ same value', function(tt) { 76 | const title = template(() => h('h1', t('title'))); 77 | tt.equal(title({ title: 'Test' }).firstChild.outerHTML, '

    Test

    '); 78 | tt.equal(title({ title: 'Test' }).firstChild.outerHTML, '

    Test

    '); 79 | tt.end(); 80 | }); 81 | 82 | test('template result fills multiple observable tags w/ same key', function(tt) { 83 | const title = template(() => 84 | h('h1', { class: o('title') }, h('b', o('title')), h('i', o('title'))) 85 | ); 86 | const obj = { 87 | title: '' 88 | }; 89 | 90 | const rendered = title(obj); 91 | obj.title = 'banana'; 92 | 93 | tt.equal( 94 | rendered.firstChild.outerHTML, 95 | '

    bananabanana

    ' 96 | ); 97 | 98 | tt.end(); 99 | }); 100 | 101 | test('template works with map', function(tt) { 102 | const Row = template( 103 | () => html` 104 | 105 | ${t('id')} 106 | ${o('label')} 107 | 108 | 109 | 114 | 115 | 116 | 117 | ` 118 | ); 119 | 120 | const rows = () => 121 | [1, 2].map(id => ({ 122 | id, 123 | label: `Label ${id}` 124 | })); 125 | 126 | const table = document.createElement('table'); 127 | table.appendChild(map(rows, Row)); 128 | 129 | tt.equal( 130 | normalizeAttributes(table.innerHTML), 131 | normalizeAttributes( 132 | ` 133 | 1 134 | Label 1 135 | 136 | 137 | 138 | 139 | 140 | 141 | 2 142 | Label 2 143 | 144 | 145 | 146 | 147 | `.replace(/>[\s]+<') 148 | ) 149 | ); 150 | 151 | tt.end(); 152 | }); 153 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import './_polyfills.js'; 2 | import './h/add-node.js'; 3 | import './h/hyperscript.js'; 4 | import './h/svg.js'; 5 | import './h/insert.js'; 6 | import './h/insert-bugs.js'; 7 | import './h/insert-markers.js'; 8 | import './h/utils.js'; 9 | import './observable/child.js'; 10 | import './observable/observable.js'; 11 | import './observable/S.js'; 12 | import './observable/sample.js'; 13 | import './observable/root.js'; 14 | import './observable/dispose.js'; 15 | import './observable/transaction.js'; 16 | import './observable/on.js'; 17 | import './observable/value.js'; 18 | import './map/map.js'; 19 | import './map/map-basic.js'; 20 | import './map/map-fragments.js'; 21 | import './map/map-objects.js'; 22 | import './map/dispose.js'; 23 | import './hydrate/hydrate.js'; 24 | import './hydrate/svg.js'; 25 | import './hydrate/selector.js'; 26 | import './template.js'; 27 | import './sinuous.js'; 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "noEmit": true, 7 | "strict": true, 8 | "allowJs": true 9 | } 10 | } 11 | --------------------------------------------------------------------------------