├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md ├── dependabot.yml └── workflows │ └── nodejs.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── API.md ├── HELP.md ├── LICENSE.txt ├── MIGRATION.md ├── README.md ├── package.json ├── readme-tonic-dark.png ├── readme-tonic.png ├── src └── index.js └── test ├── fixtures ├── htm.js └── preact.js ├── index.js └── perf └── perf.js /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Do you want to request a *feature* or report a *bug*?** 8 | 9 | **If the current behavior is a feature request, please frame a clear argument for it and provide a use-case for why it should be added to the library instead of in your own fork** 10 | 11 | **If the current behavior is a bug, please provide the steps to reproduce in this issue as well as a link to a minimal demo on something like JSFiddle or CodePen (try to reduce the problem by removing any dependencies or unrelated code). Your bug will get fixed much faster if we can reproduce the problem.** 12 | 13 | **Describe the bug** 14 | 15 | A clear and concise description of what the bug is. 16 | 17 | **Steps To Reproduce** 18 | 19 | Steps to reproduce the behavior: 20 | 1. Go to '...' 21 | 2. Click on '....' 22 | 3. Scroll down to '....' 23 | 4. See error 24 | 25 | **Expected behavior** 26 | 27 | A clear and concise description of what you expected to happen. 28 | 29 | **Actual behavior** 30 | 31 | A clear and concise description of what actually happened. 32 | 33 | **Screenshots** 34 | 35 | If applicable, add screenshots to help explain your problem. 36 | 37 | **Desktop (please complete the following information):** 38 | 39 | - OS: [e.g. iOS] 40 | - Browser [e.g. chrome, safari] 41 | - Version [e.g. 22] 42 | 43 | **Smartphone (please complete the following information):** 44 | 45 | - Device: [e.g. iPhone6] 46 | - OS: [e.g. iOS8.1] 47 | - Browser [e.g. stock browser, safari] 48 | - Version [e.g. 22] 49 | 50 | **Additional context** 51 | 52 | Add any other context about the problem here. 53 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | timeout-minutes: 5 10 | 11 | strategy: 12 | matrix: 13 | node-version: ['lts/*'] 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm install 22 | - run: npm run build --if-present 23 | - run: npm run lint 24 | - name: Run tape tests 25 | run: | 26 | xvfb-run --server-args="-screen 0 1920x1080x24" npm run ci:test:tape-run 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /index.js 4 | package-lock.json 5 | .source.* 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.png 2 | .source.* 3 | .github/ 4 | test/ 5 | src/ 6 | !index.js 7 | !dist/* 8 | *.md -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag=latest 2 | save-exact=true 3 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | # APIs 2 | 3 | ## METHODS IMPLEMENTED BY THE COMPONENT DEVELOPER 4 | 5 | | Name | Description | 6 | | :--- | :--- | 7 | | `render()` | Required, should return a template literal of HTML. There is no need to call this directly, the browser will call it. Can be `sync`, `async` or `async generator`. | 8 | | `stylesheet()` | Optional, Should return a string of css to be added to a `style` tag in the component (ideal for components that use a shadow dom). | 9 | | `static stylesheet()` | Optional, Should return a string of css to be lazily added to a `style` tag in the head (ideal for custom elements with no shadow dom). | 10 | | `styles()` | Optional, Should return an object that represents inline-styles to be applied to the component. Styles are applied by adding a keys from the object to the `styles` attribute of an html tag in the render function, for example `styles="key1 key2"`. Each object's key-value pair are added to the element's style object. | 11 | 12 | ## STATIC METHODS 13 | 14 | | Method | Description | 15 | | :--- | :--- | 16 | | `add(Class, String?)` | Register a class as a new custom-tag and provide options for it. You can pass an optional string that is the HTML tagName for the custom component.| 17 | | `escape(String)` | Escapes HTML characters from a string (based on [he][3]). | 18 | | `unsafeRawString(String)` | Insert raw text in html\`...\`. Be careful with calling `unsafeRawString` on untrusted text like user input as that is an XSS attack vector. 19 | | `match(Node, Selector)` | Match the given node against a selector or any matching parent of the given node. This is useful when trying to locate a node from the actual node that was interacted with. | 20 | 21 | ## INSTANCE METHODS 22 | 23 | | Method | Description | 24 | | :--- | :--- | 25 | | reRender(Object | Function) | Set the properties of a component instance. Can also take a function which will receive the current props as an argument. | 26 | | html\`...\` | Interpolated HTML string (use as a [tagged template][2]). Provides...
1. Pass object references as properties.
2. Spread operator (ie ``) which turns ojbects into html properties.
3. Automatic string escaping.
4. Render `NamedNodeMap`, `HTMLElement`, `HTMLCollection`, or `NodeList` as html (ie `${span}`).
| 27 | | `dispatch(String, Object?)` | Dispatch a custom event from the component with an optional detail. A parent component can listen for the event by calling `this.addEventListener(String, this)` and implementing the event handler method. | 28 | 29 | ## INSTANCE PROPERTIES 30 | 31 | | Name | Description | 32 | | :--- | :--- | 33 | | elements | An array of the original child *elements* of the component. | 34 | | nodes | An array of the original child *nodes* of the component. | 35 | | props | An object that contains the properties that were passed to the component. | 36 | | state | A plain-old JSON object that contains the state of the component. | 37 | 38 | ## "LIFECYCLE" INSTANCE METHODS 39 | 40 | | Method | Description | 41 | | :--- | :--- | 42 | | `constructor(object)` | An instance of the element is created or upgraded. Useful for initializing state, setting up event listeners, or creating shadow dom. See the spec for restrictions on what you can do in the constructor. The constructor's arguments must be forwarded by calling `super(object)`. | 43 | | `willConnect()` | Called prior to the element being inserted into the DOM. Useful for updating configuration, state and preparing for the render. | 44 | | `willRender()` | Called prior to the element being rendered. Useful for getting properties of the current document, for example the scrollTop of a div. | 45 | | `connected()` | Called every time the element is inserted into the DOM. Useful for running setup code, such as fetching resources or rendering. Generally, you should try to delay work until this time. | 46 | | `disconnected()` | Called every time the element is removed from the DOM. Useful for running clean up code. | 47 | | `updated(oldProps)` | Called after reRender() is called. This method is not called on the initial render. | 48 | 49 | [1]:https://developers.google.com/web/fundamentals/web-components/customelements 50 | [2]:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals 51 | [3]:https://github.com/mathiasbynens/he 52 | -------------------------------------------------------------------------------- /HELP.md: -------------------------------------------------------------------------------- 1 | # TROUBLE SHOOTING 2 | 3 | ### Class Name Mangling 4 | 5 | To avoid class name mangling issues, we recommend using the second parameter in `Tonic.add` method: 6 | 7 | ```js 8 | Tonic.add(MyComponent, 'my-component') 9 | ``` 10 | 11 | --- 12 | 13 | If you are using Uglify (or something similar), it will mangle your class names. 14 | To fix this, just pass the `keep_fnames` option babel-minify has something 15 | similar. 16 | 17 | ```js 18 | new UglifyJsPlugin({ 19 | uglifyOptions: { 20 | keep_fnames: true 21 | }, 22 | extractComments: true, 23 | parallel: true 24 | }) 25 | ``` 26 | 27 | ## Webpack 4+ Mangling Error 28 | 29 | If you get an error in the JS console around mangling it can likely be fixed. With Webpack 4+, minimizers, such as Uglify which is now bundled, are managed via [`optimization.minimizer`][0]. Setting Uglify settings via `plugins: {}` will probably not work as needed/expected. The following should work. 30 | 31 | ```js 32 | ... 33 | optimization: { 34 | minimizer: [ 35 | new UglifyJsPlugin({ 36 | uglifyOptions: { 37 | keep_fnames: true 38 | }, 39 | extractComments: true, 40 | parallel: true 41 | }) 42 | ] 43 | } 44 | ... 45 | ``` 46 | 47 | ## Babel Transpiler Issues 48 | 49 | If you see a console error log similar to `class constructors must be invoked with |new|` then read on... 50 | 51 | Built-in classes such as Date, Array, DOM etc cannot be properly subclassed due 52 | to limitations in ES5. This babel plugin will usually fix this problem. 53 | 54 | ### Babel < 7 55 | 56 | ```js 57 | { 58 | test: /\.js$/, 59 | exclude: /node_modules/, 60 | loader: 'babel-loader', 61 | query: { 62 | presets: [['env', { exclude: ['transform-es2015-classes'] }]] 63 | } 64 | } 65 | ``` 66 | 67 | ### Babel 7+ 68 | 69 | For Babel 7+ the syntax is slightly different. You'll need to use the new `@babel...` package name and provide some limitation on browser compatability. As per [Babel Docs][1] the following will net you support for browsers with >0.25% market share while also ignoring browsers that no longer receive security updates. 70 | 71 | ```js 72 | { 73 | test: /\.js$/, 74 | exclude: /node_modules/, 75 | loader: 'babel-loader', 76 | query: { 77 | presets: [ 78 | ['@babel/preset-env', { 79 | exclude: [ 'transform-classes'], 80 | useBuiltIns: "entry" 81 | }] 82 | ] 83 | } 84 | } 85 | ``` 86 | 87 | [0]: https://webpack.js.org/configuration/optimization/#optimization-minimizer 88 | [1]: https://babeljs.io/docs/en/babel-preset-env#browserslist-integration 89 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | THE MIT LICENSE (MIT) 2 | 3 | Copyright © 2018 Paolo Fragomeni 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the “Software”), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MIGRATION.md: -------------------------------------------------------------------------------- 1 | # Migration from v13 or v14 to v15 2 | 3 | In version 15 we've migrated to ES modules and dropped support for CommonJS. 4 | You still can use CommonJS if you want with the help of bundlers like esbuild, 5 | Webpack or Rollup. 6 | 7 | There's a few breaking changes in this release: `Tonic.version`, `Tonic.SPREAD`, `Tonic.ESC`, `Tonic.AsyncFunctionGenerator`, `AsyncFunction` and `Tonic.MAP` are getters now. This change shouldn't affect most of developers. 8 | 9 | Bundle size was reduced as we don't bundle `package.json` anymore and set the version on a build step. 10 | 11 | # Migration from v12 to v13 12 | 13 | We enabled escaping on strings returned by `render()` 14 | 15 | If your `render()` method returns a `'string'` or uses a plain 16 | template tag like "return \`<div></div>\`" then this will 17 | be escaped and rendered as text content. 18 | 19 | You will have to update your `render() {}` methods to use 20 | the `this.html` method for rendering. 21 | 22 | We've renamed the method `Tonic.raw` => `Tonic.unsafeRawString`. 23 | This makes it clear that the method is unsafe and exposes your 24 | application to security issues. 25 | 26 | We strongly recommend replacing all uses of `Tonic.raw` with 27 | `this.html` instead. For the use case of repeating templates 28 | you can pass an array of `TonicTemplate` objects returned 29 | from `this.html` into another `this.html`. 30 | 31 | If you truly do need an `unsafeRawString` that is assigned as 32 | raw HTML we recommend that you use `Tonic.escape()` to build 33 | that string and review it very carefully, add a comment explaining 34 | it too. 35 | 36 | We renamed a field from `isTonicRaw` to `isTonicTemplate` on 37 | the `TonicRaw` / `TonicTemplate` class. This is unlikely to break 38 | your app. 39 | 40 | # Migrating from v11 to v12 41 | 42 | We made a breaking change where the `id` attribute is mandatory 43 | for any tonic components that access `this.id` or `this.state`. 44 | 45 | Previously `id` attribute was not mandatory but the component 46 | was in a half broken state since the `this.state` was not 47 | stored upon re-render. 48 | 49 | Now tonic warns you that the component is stateful and that the 50 | `id` is mandatory as its the primary key by which we store the 51 | `state` and get the previous state upon re-render. 52 | 53 | Basically `this.state` is semi-broken without an `id` attribute. 54 | 55 | You will get exceptions and you will have to refactor; for example 56 | 57 | ```js 58 | - 59 | + 60 | 61 | render () { 62 | - return ` 63 | + return this.html` 64 |
New Folder
65 |
66 | ${someStr}
`;``. 83 | 84 | This breaks some existing patterns that are common in applications 85 | like the following 86 | 87 | ```js 88 | class Comp extends Tonic { 89 | renderLabel () { 90 | return `` 91 | } 92 | 93 | render () { 94 | return this.html` 95 |
96 |
Some header
97 | ${this.renderLabel()} 98 |
99 | ` 100 | } 101 | } 102 | ``` 103 | 104 | In this case the HTML returned from `this.renderLabel()` is now 105 | being escaped which is probably not what you meant. 106 | 107 | You will have to patch the code to use `this.html` for the 108 | implementation of `renderLabel()` like 109 | 110 | ```js 111 | renderLabel () { 112 | return this.html`` 113 | } 114 | ``` 115 | 116 | Or to call `Tonic.raw()` manually like 117 | 118 | ```js 119 | render () { 120 | return this.html` 121 |
122 |
Some header
123 | ${Tonic.raw(this.renderLabel())} 124 |
125 | ` 126 | } 127 | ``` 128 | 129 | If you want to quickly find all occurences of the above patterns 130 | you can run the following git grep on your codebase. 131 | 132 | ```sh 133 | git grep -C10 '${' | grep ')}' 134 | ``` 135 | 136 | The fix is to add `this.html` calls in various places. 137 | 138 | We have updated `@socketsupply/components` and you will have to 139 | update to version `7.4.0` as well 140 | 141 | ```sh 142 | npm install @socketsupply/components@^7.4.0 -ES 143 | ``` 144 | 145 | There are other situations in which the increased escaping from 146 | `Tonic.escape()` like for example escaping the `"` character if 147 | you dynamically generate optional attributes 148 | 149 | Like: 150 | 151 | ```js 152 | class Icon extends Tonic { 153 | render () { 154 | return this.html` 155 | 159 | ` 160 | } 161 | } 162 | ``` 163 | 164 | In the above example we do ``fill ? `fill="${fill}"` : ''`` which 165 | leads to `"` getting escaped to `"` and leads to the value 166 | of `use.getAttribute('fill')` to be `"${fill}"` instead of `${fill}` 167 | 168 | Here is a regex you can use to find the one-liner use cases. 169 | 170 | ``` 171 | git grep -E '`(.+)="' 172 | ``` 173 | 174 | When building dynamic attribute lists `Tonic` has a spread feature 175 | in the `this.html()` function you can use instead to make it easier. 176 | 177 | For example, you can refactor the above `Icon` class to: 178 | 179 | ```js 180 | class Icon extends Tonic { 181 | render () { 182 | return this.html` 183 | 189 | ` 190 | } 191 | } 192 | ``` 193 | 194 | Here we use `...${{ ... }}` to expand an object of attributes to 195 | attribute key value pairs in the HTML. You can also pull out the attrs 196 | into a reference if you prefer, like: 197 | 198 | ```js 199 | class Icon extends Tonic { 200 | render () { 201 | const useAttrs = { 202 | width: size, 203 | fill, 204 | color: fill, 205 | height: size 206 | } 207 | return this.html` 208 | 209 | ` 210 | } 211 | } 212 | ``` 213 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | tonic 5 | 6 | 7 |

8 | https://tonicframework.dev 9 |

10 |
11 |
12 | Tonic is a low profile component framework for the web. It's one file, less than 3kb gzipped and has no dependencies. It's designed to be used with modern Javascript and is compatible with all modern browsers and built on top of the Web Components. It was designed to be similar to React but 100x easier to reason about. 13 | 14 | ## Installation 15 | 16 | ```sh 17 | npm install @socketsupply/tonic 18 | ``` 19 | 20 | ## Usage 21 | 22 | ```js 23 | import Tonic from '@socketsupply/tonic' 24 | ``` 25 | 26 | You can use functions as components. They can be async or even an async generator function. 27 | 28 | ```js 29 | async function MyGreeting () { 30 | const data = await (await fetch('https://example.com/data')).text() 31 | return this.html`

Hello, ${data}

` 32 | } 33 | ``` 34 | 35 | Or you can use classes. Every class must have a render method. 36 | 37 | ```js 38 | class MyGreeting extends Tonic { 39 | async * render () { 40 | yield this.html`
Loading...
` 41 | 42 | const data = await (await fetch('https://example.com/data')).text() 43 | return this.html`
Hello, ${data}.
` 44 | } 45 | } 46 | ``` 47 | 48 | ```js 49 | Tonic.add(MyGreeting, 'my-greeting') 50 | ``` 51 | 52 | After adding your Javascript to your HTML, you can use your component anywhere. 53 | 54 | ```html 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | ``` 64 | 65 | # Useful links 66 | - [Tonic components](https://github.com/socketsupply/components) 67 | - [Migration from the early versions of Tonic](./MIGRATION.md) 68 | - [API](./API.md) 69 | - [Troubleshooting](./HELP.md) 70 | 71 | Copyright (c) 2023 Socket Supply Co. 72 | 73 | MIT License 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@socketsupply/tonic", 3 | "version": "15.1.2", 4 | "description": "A component framework.", 5 | "scripts": { 6 | "lint": "standard .", 7 | "test": "npm run build && npm run lint && esbuild --bundle test/index.js | tape-run", 8 | "ci:test:tape-run": "esbuild --bundle test/index.js | tape-run", 9 | "test:open": "npm run build && esbuild --bundle test/index.js | tape-run --browser chrome --keep-open", 10 | "build:base": "esbuild src/index.js --define:VERSION=\\\"$npm_package_version\\\" --outfile=index.js", 11 | "build:minify": "esbuild index.js --keep-names --minify --outfile=dist/tonic.min.js", 12 | "build": "npm run build:base && npm run build:minify", 13 | "prepublishOnly": "npm run build", 14 | "pub": "npm run build && npm run test && npm publish --registry=https://registry.npmjs.org && npm publish --registry https://npm.pkg.github.com" 15 | }, 16 | "main": "index.js", 17 | "type": "module", 18 | "author": "socketsupply", 19 | "license": "MIT", 20 | "devDependencies": { 21 | "benchmark": "^2.1.4", 22 | "esbuild": "^0.19.0", 23 | "standard": "^17.0.0", 24 | "tape-run": "^11.0.0", 25 | "@socketsupply/tapzero": "^0.8.0", 26 | "uuid": "^9.0.0" 27 | }, 28 | "standard": { 29 | "ignore": [ 30 | "test/fixtures/*" 31 | ] 32 | }, 33 | "directories": { 34 | "test": "test" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/socketsupply/tonic.git" 39 | }, 40 | "bugs": { 41 | "url": "https://github.com/socketsupply/tonic/issues" 42 | }, 43 | "homepage": "https://tonicframework.dev", 44 | "dependencies": {} 45 | } 46 | -------------------------------------------------------------------------------- /readme-tonic-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketsupply/tonic/648b5f7dc80ff86ce36b512d00657298e0c6c855/readme-tonic-dark.png -------------------------------------------------------------------------------- /readme-tonic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketsupply/tonic/648b5f7dc80ff86ce36b512d00657298e0c6c855/readme-tonic.png -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | class TonicTemplate { 3 | constructor (rawText, templateStrings, unsafe) { 4 | this.isTonicTemplate = true 5 | this.unsafe = unsafe 6 | this.rawText = rawText 7 | this.templateStrings = templateStrings 8 | } 9 | 10 | valueOf () { return this.rawText } 11 | toString () { return this.rawText } 12 | } 13 | 14 | export class Tonic extends window.HTMLElement { 15 | static _tags = '' 16 | static _refIds = [] 17 | static _data = {} 18 | static _states = {} 19 | static _children = {} 20 | static _reg = {} 21 | static _stylesheetRegistry = [] 22 | static _index = 0 23 | // eslint-disable-next-line no-undef 24 | static get version () { return VERSION ?? null } 25 | static get SPREAD () { return /\.\.\.\s?(__\w+__\w+__)/g } 26 | static get ESC () { return /["&'<>`/]/g } 27 | static get AsyncFunctionGenerator () { return async function * () {}.constructor } 28 | static get AsyncFunction () { return async function () {}.constructor } 29 | static get MAP () { return { '"': '"', '&': '&', '\'': ''', '<': '<', '>': '>', '`': '`', '/': '/' } } 30 | 31 | constructor () { 32 | super() 33 | const state = Tonic._states[super.id] 34 | delete Tonic._states[super.id] 35 | this._state = state || {} 36 | this.preventRenderOnReconnect = false 37 | this.props = {} 38 | this.elements = [...this.children] 39 | this.elements.__children__ = true 40 | this.nodes = [...this.childNodes] 41 | this.nodes.__children__ = true 42 | this._events() 43 | } 44 | 45 | get isTonicComponent () { 46 | return true 47 | } 48 | 49 | static _createId () { 50 | return `tonic${Tonic._index++}` 51 | } 52 | 53 | static _normalizeAttrs (o, x = {}) { 54 | [...o].forEach(o => (x[o.name] = o.value)) 55 | return x 56 | } 57 | 58 | _checkId () { 59 | const _id = super.id 60 | if (!_id) { 61 | const html = this.outerHTML.replace(this.innerHTML, '...') 62 | throw new Error(`Component: ${html} has no id`) 63 | } 64 | return _id 65 | } 66 | 67 | get state () { 68 | return (this._checkId(), this._state) 69 | } 70 | 71 | set state (newState) { 72 | this._state = (this._checkId(), newState) 73 | } 74 | 75 | _events () { 76 | const hp = Object.getOwnPropertyNames(window.HTMLElement.prototype) 77 | for (const p of this._props) { 78 | if (hp.indexOf('on' + p) === -1) continue 79 | this.addEventListener(p, this) 80 | } 81 | } 82 | 83 | _prop (o) { 84 | const id = this._id 85 | const p = `__${id}__${Tonic._createId()}__` 86 | Tonic._data[id] = Tonic._data[id] || {} 87 | Tonic._data[id][p] = o 88 | return p 89 | } 90 | 91 | _placehold (r) { 92 | const id = this._id 93 | const ref = `placehold:${id}:${Tonic._createId()}__` 94 | Tonic._children[id] = Tonic._children[id] || {} 95 | Tonic._children[id][ref] = r 96 | return ref 97 | } 98 | 99 | static match (el, s) { 100 | if (!el.matches) el = el.parentElement 101 | return el.matches(s) ? el : el.closest(s) 102 | } 103 | 104 | static getTagName (camelName) { 105 | return camelName.match(/[A-Z][a-z0-9]*/g).join('-').toLowerCase() 106 | } 107 | 108 | static getPropertyNames (proto) { 109 | const props = [] 110 | while (proto && proto !== Tonic.prototype) { 111 | props.push(...Object.getOwnPropertyNames(proto)) 112 | proto = Object.getPrototypeOf(proto) 113 | } 114 | return props 115 | } 116 | 117 | static add (c, htmlName) { 118 | const hasValidName = htmlName || (c.name && c.name.length > 1) 119 | if (!hasValidName) { 120 | throw Error('Mangling. https://bit.ly/2TkJ6zP') 121 | } 122 | 123 | if (!htmlName) htmlName = Tonic.getTagName(c.name) 124 | if (!Tonic.ssr && window.customElements.get(htmlName)) { 125 | throw new Error(`Cannot Tonic.add(${c.name}, '${htmlName}') twice`) 126 | } 127 | 128 | if (!c.prototype || !c.prototype.isTonicComponent) { 129 | const tmp = { [c.name]: class extends Tonic {} }[c.name] 130 | tmp.prototype.render = c 131 | c = tmp 132 | } 133 | 134 | c.prototype._props = Tonic.getPropertyNames(c.prototype) 135 | 136 | Tonic._reg[htmlName] = c 137 | Tonic._tags = Object.keys(Tonic._reg).join() 138 | window.customElements.define(htmlName, c) 139 | 140 | if (typeof c.stylesheet === 'function') { 141 | Tonic.registerStyles(c.stylesheet) 142 | } 143 | 144 | return c 145 | } 146 | 147 | static registerStyles (stylesheetFn) { 148 | if (Tonic._stylesheetRegistry.includes(stylesheetFn)) return 149 | Tonic._stylesheetRegistry.push(stylesheetFn) 150 | 151 | const styleNode = document.createElement('style') 152 | if (Tonic.nonce) styleNode.setAttribute('nonce', Tonic.nonce) 153 | styleNode.appendChild(document.createTextNode(stylesheetFn())) 154 | if (document.head) document.head.appendChild(styleNode) 155 | } 156 | 157 | static escape (s) { 158 | return s.replace(Tonic.ESC, c => Tonic.MAP[c]) 159 | } 160 | 161 | static unsafeRawString (s, templateStrings) { 162 | return new TonicTemplate(s, templateStrings, true) 163 | } 164 | 165 | dispatch (eventName, detail = null) { 166 | const opts = { bubbles: true, detail } 167 | this.dispatchEvent(new window.CustomEvent(eventName, opts)) 168 | } 169 | 170 | html (strings, ...values) { 171 | const refs = o => { 172 | if (o && o.__children__) return this._placehold(o) 173 | if (o && o.isTonicTemplate) return o.rawText 174 | switch (Object.prototype.toString.call(o)) { 175 | case '[object HTMLCollection]': 176 | case '[object NodeList]': return this._placehold([...o]) 177 | case '[object Array]': { 178 | if (o.every(x => x.isTonicTemplate && !x.unsafe)) { 179 | return new TonicTemplate(o.join('\n'), null, false) 180 | } 181 | return this._prop(o) 182 | } 183 | case '[object Object]': 184 | case '[object Function]': 185 | case '[object AsyncFunction]': 186 | case '[object Set]': 187 | case '[object Map]': 188 | case '[object WeakMap]': 189 | case '[object File]': 190 | return this._prop(o) 191 | case '[object NamedNodeMap]': 192 | return this._prop(Tonic._normalizeAttrs(o)) 193 | case '[object Number]': return `${o}__float` 194 | case '[object String]': return Tonic.escape(o) 195 | case '[object Boolean]': return `${o}__boolean` 196 | case '[object Null]': return `${o}__null` 197 | case '[object HTMLElement]': 198 | return this._placehold([o]) 199 | } 200 | if ( 201 | typeof o === 'object' && o && o.nodeType === 1 && 202 | typeof o.cloneNode === 'function' 203 | ) { 204 | return this._placehold([o]) 205 | } 206 | return o 207 | } 208 | 209 | const out = [] 210 | for (let i = 0; i < strings.length - 1; i++) { 211 | out.push(strings[i], refs(values[i])) 212 | } 213 | out.push(strings[strings.length - 1]) 214 | 215 | const htmlStr = out.join('').replace(Tonic.SPREAD, (_, p) => { 216 | const o = Tonic._data[p.split('__')[1]][p] 217 | return Object.entries(o).map(([key, value]) => { 218 | const k = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() 219 | if (value === true) return k 220 | else if (value) return `${k}="${Tonic.escape(String(value))}"` 221 | else return '' 222 | }).filter(Boolean).join(' ') 223 | }) 224 | return new TonicTemplate(htmlStr, strings, false) 225 | } 226 | 227 | scheduleReRender (oldProps) { 228 | if (this.pendingReRender) return this.pendingReRender 229 | 230 | this.pendingReRender = new Promise(resolve => setTimeout(() => { 231 | if (!this.isInDocument(this.shadowRoot || this)) return 232 | const p = this._set(this.shadowRoot || this, this.render) 233 | this.pendingReRender = null 234 | 235 | if (p && p.then) { 236 | return p.then(() => { 237 | this.updated && this.updated(oldProps) 238 | resolve(this) 239 | }) 240 | } 241 | 242 | this.updated && this.updated(oldProps) 243 | resolve(this) 244 | }, 0)) 245 | 246 | return this.pendingReRender 247 | } 248 | 249 | reRender (o = this.props) { 250 | const oldProps = { ...this.props } 251 | this.props = typeof o === 'function' ? o(oldProps) : o 252 | return this.scheduleReRender(oldProps) 253 | } 254 | 255 | handleEvent (e) { 256 | this[e.type](e) 257 | } 258 | 259 | _drainIterator (target, iterator) { 260 | return iterator.next().then((result) => { 261 | this._set(target, null, result.value) 262 | if (result.done) return 263 | return this._drainIterator(target, iterator) 264 | }) 265 | } 266 | 267 | _set (target, render, content = '') { 268 | this.willRender && this.willRender() 269 | for (const node of target.querySelectorAll(Tonic._tags)) { 270 | if (!node.isTonicComponent) continue 271 | 272 | const id = node.getAttribute('id') 273 | if (!id || !Tonic._refIds.includes(id)) continue 274 | Tonic._states[id] = node.state 275 | } 276 | 277 | if (render instanceof Tonic.AsyncFunction) { 278 | return (render 279 | .call(this, this.html, this.props) 280 | .then(content => this._apply(target, content)) 281 | ) 282 | } else if (render instanceof Tonic.AsyncFunctionGenerator) { 283 | return this._drainIterator(target, render.call(this)) 284 | } else if (render === null) { 285 | this._apply(target, content) 286 | } else if (render instanceof Function) { 287 | this._apply(target, render.call(this, this.html, this.props) || '') 288 | } 289 | } 290 | 291 | _apply (target, content) { 292 | if (content && content.isTonicTemplate) { 293 | content = content.rawText 294 | } else if (typeof content === 'string') { 295 | content = Tonic.escape(content) 296 | } 297 | 298 | if (typeof content === 'string') { 299 | if (this.stylesheet) { 300 | content = `${content}` 301 | } 302 | 303 | target.innerHTML = content 304 | 305 | if (this.styles) { 306 | const styles = this.styles() 307 | for (const node of target.querySelectorAll('[styles]')) { 308 | for (const s of node.getAttribute('styles').split(/\s+/)) { 309 | Object.assign(node.style, styles[s.trim()]) 310 | } 311 | } 312 | } 313 | 314 | const children = Tonic._children[this._id] || {} 315 | 316 | const walk = (node, fn) => { 317 | if (node.nodeType === 3) { 318 | const id = node.textContent.trim() 319 | if (children[id]) fn(node, children[id], id) 320 | } 321 | 322 | const childNodes = node.childNodes 323 | if (!childNodes) return 324 | 325 | for (let i = 0; i < childNodes.length; i++) { 326 | walk(childNodes[i], fn) 327 | } 328 | } 329 | 330 | walk(target, (node, children, id) => { 331 | for (const child of children) { 332 | node.parentNode.insertBefore(child, node) 333 | } 334 | delete Tonic._children[this._id][id] 335 | node.parentNode.removeChild(node) 336 | }) 337 | } else { 338 | target.innerHTML = '' 339 | target.appendChild(content.cloneNode(true)) 340 | } 341 | } 342 | 343 | connectedCallback () { 344 | this.root = this.shadowRoot || this // here for back compat 345 | 346 | if (super.id && !Tonic._refIds.includes(super.id)) { 347 | Tonic._refIds.push(super.id) 348 | } 349 | const cc = s => s.replace(/-(.)/g, (_, m) => m.toUpperCase()) 350 | 351 | for (const { name: _name, value } of this.attributes) { 352 | const name = cc(_name) 353 | const p = this.props[name] = value 354 | 355 | if (/__\w+__\w+__/.test(p)) { 356 | const { 1: root } = p.split('__') 357 | this.props[name] = Tonic._data[root][p] 358 | } else if (/\d+__float/.test(p)) { 359 | this.props[name] = parseFloat(p, 10) 360 | } else if (p === 'null__null') { 361 | this.props[name] = null 362 | } else if (/\w+__boolean/.test(p)) { 363 | this.props[name] = p.includes('true') 364 | } else if (/placehold:\w+:\w+__/.test(p)) { 365 | const { 1: root } = p.split(':') 366 | this.props[name] = Tonic._children[root][p][0] 367 | } 368 | } 369 | 370 | this.props = Object.assign( 371 | this.defaults ? this.defaults() : {}, 372 | this.props 373 | ) 374 | 375 | this._id = this._id || Tonic._createId() 376 | 377 | this.willConnect && this.willConnect() 378 | 379 | if (!this.isInDocument(this.root)) return 380 | if (!this.preventRenderOnReconnect) { 381 | if (!this._source) { 382 | this._source = this.innerHTML 383 | } else { 384 | this.innerHTML = this._source 385 | } 386 | const p = this._set(this.root, this.render) 387 | if (p && p.then) return p.then(() => this.connected && this.connected()) 388 | } 389 | 390 | this.connected && this.connected() 391 | } 392 | 393 | isInDocument (target) { 394 | const root = target.getRootNode() 395 | return root === document || root.toString() === '[object ShadowRoot]' 396 | } 397 | 398 | disconnectedCallback () { 399 | this.disconnected && this.disconnected() 400 | delete Tonic._data[this._id] 401 | delete Tonic._children[this._id] 402 | } 403 | } 404 | 405 | export default Tonic 406 | -------------------------------------------------------------------------------- /test/fixtures/htm.js: -------------------------------------------------------------------------------- 1 | var e=function(){},t={},n=[],o=[];function r(t,r){var i,l,a,s,p=arguments,u=o;for(s=arguments.length;s-- >2;)n.push(p[s]);for(r&&null!=r.children&&(n.length||n.push(r.children),delete r.children);n.length;)if((l=n.pop())&&void 0!==l.pop)for(s=l.length;s--;)n.push(l[s]);else"boolean"==typeof l&&(l=null),(a="function"!=typeof t)&&(null==l?l="":"number"==typeof l?l=String(l):"string"!=typeof l&&(a=!1)),a&&i?u[u.length-1]+=l:u===o?u=[l]:u.push(l),i=a;var c=new e;return c.nodeName=t,c.children=u,c.attributes=null==r?void 0:r,c.key=null==r?void 0:r.key,c}function i(e,t){for(var n in t)e[n]=t[n];return e}function l(e,t){null!=e&&("function"==typeof e?e(t):e.current=t)}var a="function"==typeof Promise?Promise.resolve().then.bind(Promise.resolve()):setTimeout,s=/acit|ex(?:s|g|n|p|$)|rph|ows|mnc|ntw|ine[ch]|zoo|^ord/i,p=[];function u(e){!e._dirty&&(e._dirty=!0)&&1==p.push(e)&&a(c)}function c(){for(var e;e=p.pop();)e._dirty&&U(e)}function f(e,t){return e.normalizedNodeName===t||e.nodeName.toLowerCase()===t.toLowerCase()}function d(e){var t=i({},e.attributes);t.children=e.children;var n=e.nodeName.defaultProps;if(void 0!==n)for(var o in n)void 0===t[o]&&(t[o]=n[o]);return t}function v(e){var t=e.parentNode;t&&t.removeChild(e)}function h(e,t,n,o,r){if("className"===t&&(t="class"),"key"===t);else if("ref"===t)l(n,null),l(o,e);else if("class"!==t||r)if("style"===t){if(o&&"string"!=typeof o&&"string"!=typeof n||(e.style.cssText=o||""),o&&"object"==typeof o){if("string"!=typeof n)for(var i in n)i in o||(e.style[i]="");for(var i in o)e.style[i]="number"==typeof o[i]&&!1===s.test(i)?o[i]+"px":o[i]}}else if("dangerouslySetInnerHTML"===t)o&&(e.innerHTML=o.__html||"");else if("o"==t[0]&&"n"==t[1]){var a=t!==(t=t.replace(/Capture$/,""));t=t.toLowerCase().substring(2),o?n||e.addEventListener(t,m,a):e.removeEventListener(t,m,a),(e._listeners||(e._listeners={}))[t]=o}else if("list"!==t&&"type"!==t&&!r&&t in e){try{e[t]=null==o?"":o}catch(e){}null!=o&&!1!==o||"spellcheck"==t||e.removeAttribute(t)}else{var p=r&&t!==(t=t.replace(/^xlink:?/,""));null==o||!1===o?p?e.removeAttributeNS("http://www.w3.org/1999/xlink",t.toLowerCase()):e.removeAttribute(t):"function"!=typeof o&&(p?e.setAttributeNS("http://www.w3.org/1999/xlink",t.toLowerCase(),o):e.setAttribute(t,o))}else e.className=o||""}function m(e){return this._listeners[e.type](e)}var _=[],y=0,g=!1,b=!1;function C(){for(var e;e=_.shift();)e.componentDidMount&&e.componentDidMount()}function x(e,t,n,o,r,i){y++||(g=null!=r&&void 0!==r.ownerSVGElement,b=null!=e&&!("__preactattr_"in e));var l=function e(t,n,o,r,i){var l=t,a=g;if(null!=n&&"boolean"!=typeof n||(n=""),"string"==typeof n||"number"==typeof n)return t&&void 0!==t.splitText&&t.parentNode&&(!t._component||i)?t.nodeValue!=n&&(t.nodeValue=n):(l=document.createTextNode(n),t&&(t.parentNode&&t.parentNode.replaceChild(l,t),N(t,!0))),l.__preactattr_=!0,l;var s,p,u=n.nodeName;if("function"==typeof u)return function(e,t,n,o){for(var r=e&&e._component,i=r,l=e,a=r&&e._componentConstructor===t.nodeName,s=a,p=d(t);r&&!s&&(r=r._parentComponent);)s=r.constructor===t.nodeName;return r&&s&&(!o||r._component)?(B(r,p,3,n,o),e=r.base):(i&&!a&&(L(i),e=l=null),r=S(t.nodeName,p,n),e&&!r.nextBase&&(r.nextBase=e,l=null),B(r,p,1,n,o),e=r.base,l&&e!==l&&(l._component=null,N(l,!1))),e}(t,n,o,r);if(g="svg"===u||"foreignObject"!==u&&g,u=String(u),(!t||!f(t,u))&&(s=u,(p=g?document.createElementNS("http://www.w3.org/2000/svg",s):document.createElement(s)).normalizedNodeName=s,l=p,t)){for(;t.firstChild;)l.appendChild(t.firstChild);t.parentNode&&t.parentNode.replaceChild(l,t),N(t,!0)}var c=l.firstChild,m=l.__preactattr_,_=n.children;if(null==m){m=l.__preactattr_={};for(var y=l.attributes,C=y.length;C--;)m[y[C].name]=y[C].value}return!b&&_&&1===_.length&&"string"==typeof _[0]&&null!=c&&void 0!==c.splitText&&null==c.nextSibling?c.nodeValue!=_[0]&&(c.nodeValue=_[0]):(_&&_.length||null!=c)&&function(t,n,o,r,i){var l,a,s,p,u,c,d,h,m=t.childNodes,_=[],y={},g=0,b=0,C=m.length,x=0,w=n?n.length:0;if(0!==C)for(var k=0;k"===t?(a(),o=1):o&&("="===t?(o=4,n=r,r=""):"/"===t?(a(),3===o&&(l=l[0]),o=l,(l=l[0]).push(o,4),o=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(a(),o=2):r+=t)}return a(),l},D="function"==typeof Map,E=D?new Map:{},V=D?function(e){var t=E.get(e);return t||E.set(e,t=W(e)),t}:function(e){for(var t="",n=0;n1?t:t[0]}.bind(r);export{r as h,H as html,A as render,T as Component}; 2 | -------------------------------------------------------------------------------- /test/fixtures/preact.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";function e(){}function t(t,n){var o,r,i,l,a=E;for(l=arguments.length;l-- >2;)W.push(arguments[l]);n&&null!=n.children&&(W.length||W.push(n.children),delete n.children);while(W.length)if((r=W.pop())&&void 0!==r.pop)for(l=r.length;l--;)W.push(r[l]);else r!==!0&&r!==!1||(r=null),(i="function"!=typeof t)&&(null==r?r="":"number"==typeof r?r+="":"string"!=typeof r&&(i=!1)),i&&o?a[a.length-1]+=r:a===E?a=[r]:a.push(r),o=i;var _=new e;return _.nodeName=t,_.children=a,_.attributes=null==n?void 0:n,_.key=null==n?void 0:n.key,void 0!==S.vnode&&S.vnode(_),_}function n(e,t){for(var n in t)e[n]=t[n];return e}function o(e,o){return t(e.nodeName,n(n({},e.attributes),o),arguments.length>2?[].slice.call(arguments,2):e.children)}function r(e){!e.__d&&(e.__d=!0)&&1==A.push(e)&&(S.debounceRendering||setTimeout)(i)}function i(){var e,t=A;A=[];while(e=t.pop())e.__d&&k(e)}function l(e,t,n){return"string"==typeof t||"number"==typeof t?void 0!==e.splitText:"string"==typeof t.nodeName?!e._componentConstructor&&a(e,t.nodeName):n||e._componentConstructor===t.nodeName}function a(e,t){return e.__n===t||e.nodeName.toLowerCase()===t.toLowerCase()}function _(e){var t=n({},e.attributes);t.children=e.children;var o=e.nodeName.defaultProps;if(void 0!==o)for(var r in o)void 0===t[r]&&(t[r]=o[r]);return t}function u(e,t){var n=t?document.createElementNS("http://www.w3.org/2000/svg",e):document.createElement(e);return n.__n=e,n}function c(e){e.parentNode&&e.parentNode.removeChild(e)}function p(e,t,n,o,r){if("className"===t&&(t="class"),"key"===t);else if("ref"===t)n&&n(null),o&&o(e);else if("class"!==t||r)if("style"===t){if(o&&"string"!=typeof o&&"string"!=typeof n||(e.style.cssText=o||""),o&&"object"==typeof o){if("string"!=typeof n)for(var i in n)i in o||(e.style[i]="");for(var i in o)e.style[i]="number"==typeof o[i]&&V.test(i)===!1?o[i]+"px":o[i]}}else if("dangerouslySetInnerHTML"===t)o&&(e.innerHTML=o.__html||"");else if("o"==t[0]&&"n"==t[1]){var l=t!==(t=t.replace(/Capture$/,""));t=t.toLowerCase().substring(2),o?n||e.addEventListener(t,d,l):e.removeEventListener(t,d,l),(e.__l||(e.__l={}))[t]=o}else if("list"!==t&&"type"!==t&&!r&&t in e)s(e,t,null==o?"":o),null!=o&&o!==!1||e.removeAttribute(t);else{var a=r&&t!==(t=t.replace(/^xlink\:?/,""));null==o||o===!1?a?e.removeAttributeNS("http://www.w3.org/1999/xlink",t.toLowerCase()):e.removeAttribute(t):"function"!=typeof o&&(a?e.setAttributeNS("http://www.w3.org/1999/xlink",t.toLowerCase(),o):e.setAttribute(t,o))}else e.className=o||""}function s(e,t,n){try{e[t]=n}catch(e){}}function d(e){return this.__l[e.type](S.event&&S.event(e)||e)}function f(){var e;while(e=D.pop())S.afterMount&&S.afterMount(e),e.componentDidMount&&e.componentDidMount()}function h(e,t,n,o,r,i){H++||(P=null!=r&&void 0!==r.ownerSVGElement,R=null!=e&&!("__preactattr_"in e));var l=m(e,t,n,o,i);return r&&l.parentNode!==r&&r.appendChild(l),--H||(R=!1,i||f()),l}function m(e,t,n,o,r){var i=e,l=P;if(null==t&&(t=""),"string"==typeof t)return e&&void 0!==e.splitText&&e.parentNode&&(!e._component||r)?e.nodeValue!=t&&(e.nodeValue=t):(i=document.createTextNode(t),e&&(e.parentNode&&e.parentNode.replaceChild(i,e),b(e,!0))),i.__preactattr_=!0,i;if("function"==typeof t.nodeName)return U(e,t,n,o);if(P="svg"===t.nodeName||"foreignObject"!==t.nodeName&&P,(!e||!a(e,t.nodeName+""))&&(i=u(t.nodeName+"",P),e)){while(e.firstChild)i.appendChild(e.firstChild);e.parentNode&&e.parentNode.replaceChild(i,e),b(e,!0)}var _=i.firstChild,c=i.__preactattr_||(i.__preactattr_={}),p=t.children;return!R&&p&&1===p.length&&"string"==typeof p[0]&&null!=_&&void 0!==_.splitText&&null==_.nextSibling?_.nodeValue!=p[0]&&(_.nodeValue=p[0]):(p&&p.length||null!=_)&&v(i,p,n,o,R||null!=c.dangerouslySetInnerHTML),g(i,t.attributes,c),P=l,i}function v(e,t,n,o,r){var i,a,_,u,p=e.childNodes,s=[],d={},f=0,h=0,v=p.length,y=0,g=t?t.length:0;if(0!==v)for(var N=0;N=v?e.appendChild(u):u!==p[N]&&(u===p[N+1]?c(p[N]):e.insertBefore(u,p[N]||null)))}if(f)for(var N in d)void 0!==d[N]&&b(d[N],!1);while(h<=y)void 0!==(u=s[y--])&&b(u,!1)}function b(e,t){var n=e._component;n?L(n):(null!=e.__preactattr_&&e.__preactattr_.ref&&e.__preactattr_.ref(null),t!==!1&&null!=e.__preactattr_||c(e),y(e))}function y(e){e=e.lastChild;while(e){var t=e.previousSibling;b(e,!0),e=t}}function g(e,t,n){var o;for(o in n)t&&null!=t[o]||null==n[o]||p(e,o,n[o],n[o]=void 0,P);for(o in t)"children"===o||"innerHTML"===o||o in n&&t[o]===("value"===o||"checked"===o?e[o]:n[o])||p(e,o,n[o],n[o]=t[o],P)}function N(e){var t=e.constructor.name;(j[t]||(j[t]=[])).push(e)}function w(e,t,n){var o,r=j[e.name];if(e.prototype&&e.prototype.render?(o=new e(t,n),T.call(o,t,n)):(o=new T(t,n),o.constructor=e,o.render=C),r)for(var i=r.length;i--;)if(r[i].constructor===e){o.__b=r[i].__b,r.splice(i,1);break}return o}function C(e,t,n){return this.constructor(e,n)}function x(e,t,n,o,i){e.__x||(e.__x=!0,(e.__r=t.ref)&&delete t.ref,(e.__k=t.key)&&delete t.key,!e.base||i?e.componentWillMount&&e.componentWillMount():e.componentWillReceiveProps&&e.componentWillReceiveProps(t,o),o&&o!==e.context&&(e.__c||(e.__c=e.context),e.context=o),e.__p||(e.__p=e.props),e.props=t,e.__x=!1,0!==n&&(1!==n&&S.syncComponentUpdates===!1&&e.base?r(e):k(e,1,i)),e.__r&&e.__r(e))}function k(e,t,o,r){if(!e.__x){var i,l,a,u=e.props,c=e.state,p=e.context,s=e.__p||u,d=e.__s||c,m=e.__c||p,v=e.base,y=e.__b,g=v||y,N=e._component,C=!1;if(v&&(e.props=s,e.state=d,e.context=m,2!==t&&e.shouldComponentUpdate&&e.shouldComponentUpdate(u,c,p)===!1?C=!0:e.componentWillUpdate&&e.componentWillUpdate(u,c,p),e.props=u,e.state=c,e.context=p),e.__p=e.__s=e.__c=e.__b=null,e.__d=!1,!C){i=e.render(u,c,p),e.getChildContext&&(p=n(n({},p),e.getChildContext()));var U,T,M=i&&i.nodeName;if("function"==typeof M){var W=_(i);l=N,l&&l.constructor===M&&W.key==l.__k?x(l,W,1,p,!1):(U=l,e._component=l=w(M,W,p),l.__b=l.__b||y,l.__u=e,x(l,W,0,p,!1),k(l,1,o,!0)),T=l.base}else a=g,U=N,U&&(a=e._component=null),(g||1===t)&&(a&&(a._component=null),T=h(a,i,p,o||!v,g&&g.parentNode,!0));if(g&&T!==g&&l!==N){var E=g.parentNode;E&&T!==E&&(E.replaceChild(T,g),U||(g._component=null,b(g,!1)))}if(U&&L(U),e.base=T,T&&!r){var V=e,A=e;while(A=A.__u)(V=A).base=T;T._component=V,T._componentConstructor=V.constructor}}if(!v||o?D.unshift(e):C||(f(),e.componentDidUpdate&&e.componentDidUpdate(s,d,m),S.afterUpdate&&S.afterUpdate(e)),null!=e.__h)while(e.__h.length)e.__h.pop().call(e);H||r||f()}}function U(e,t,n,o){var r=e&&e._component,i=r,l=e,a=r&&e._componentConstructor===t.nodeName,u=a,c=_(t);while(r&&!u&&(r=r.__u))u=r.constructor===t.nodeName;return r&&u&&(!o||r._component)?(x(r,c,3,n,o),e=r.base):(i&&!a&&(L(i),e=l=null),r=w(t.nodeName,c,n),e&&!r.__b&&(r.__b=e,l=null),x(r,c,1,n,o),e=r.base,l&&e!==l&&(l._component=null,b(l,!1))),e}function L(e){S.beforeUnmount&&S.beforeUnmount(e);var t=e.base;e.__x=!0,e.componentWillUnmount&&e.componentWillUnmount(),e.base=null;var n=e._component;n?L(n):t&&(t.__preactattr_&&t.__preactattr_.ref&&t.__preactattr_.ref(null),e.__b=t,c(t),N(e),y(t)),e.__r&&e.__r(null)}function T(e,t){this.__d=!0,this.context=t,this.props=e,this.state=this.state||{}}function M(e,t,n){return h(n,e,{},!1,t,!1)}var S={},W=[],E=[],V=/acit|ex(?:s|g|n|p|$)|rph|ows|mnc|ntw|ine[ch]|zoo|^ord/i,A=[],D=[],H=0,P=!1,R=!1,j={};n(T.prototype,{setState:function(e,t){var o=this.state;this.__s||(this.__s=n({},o)),n(o,"function"==typeof e?e(o,this.props):e),t&&(this.__h=this.__h||[]).push(t),r(this)},forceUpdate:function(e){e&&(this.__h=this.__h||[]).push(e),k(this,2)},render:function(){}});var I={h:t,createElement:t,cloneElement:o,Component:T,render:M,rerender:i,options:S};"undefined"!=typeof module?module.exports=I:self.preact=I}(); 2 | //# sourceMappingURL=preact.min.js.map 3 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import { test } from '@socketsupply/tapzero' 2 | import { v4 as uuid } from 'uuid' 3 | import Tonic from '../index.js' 4 | 5 | const sleep = async t => new Promise(resolve => setTimeout(resolve, t)) 6 | 7 | test('sanity', async t => { 8 | t.ok(true) 9 | 10 | const version = Tonic.version 11 | const parts = version.split('.') 12 | t.ok(parseInt(parts[0]) >= 10) 13 | }) 14 | 15 | test('pass an async function as an event handler', t => { 16 | t.plan(1) 17 | 18 | class TheApp extends Tonic { 19 | async clicker (msg) { 20 | t.equal(msg, 'hello', 'should get the event') 21 | } 22 | 23 | render () { 24 | return this.html`
25 | 26 |
` 27 | } 28 | } 29 | 30 | class FnExample extends Tonic { 31 | click (ev) { 32 | ev.preventDefault() 33 | this.props.onbtnclick('hello') 34 | } 35 | 36 | render () { 37 | return this.html`
38 | example 39 | 40 |
` 41 | } 42 | } 43 | 44 | document.body.innerHTML = ` 45 | 46 | ` 47 | 48 | Tonic.add(FnExample) 49 | Tonic.add(TheApp) 50 | 51 | document.getElementById('btn').click() 52 | }) 53 | 54 | test('get kebab case from camel case', t => { 55 | const kebab = Tonic.getTagName('MyExample') 56 | t.equal(typeof kebab, 'string', 'should return a string') 57 | t.equal(kebab, 'my-example', 'should create kebab case given camel case') 58 | 59 | class MyExample extends Tonic { 60 | render () { 61 | return this.html`
example
` 62 | } 63 | } 64 | t.equal(Tonic.getTagName(MyExample.name), 'my-example', 65 | 'example using a tonic component') 66 | }) 67 | 68 | test('attach to dom', async t => { 69 | class ComponentA extends Tonic { 70 | render () { 71 | return this.html`
` 72 | } 73 | } 74 | 75 | document.body.innerHTML = ` 76 | 77 | ` 78 | 79 | Tonic.add(ComponentA) 80 | 81 | const div = document.querySelector('div') 82 | t.ok(div, 'a div was created and attached') 83 | }) 84 | 85 | test('render-only component', async t => { 86 | function ComponentFun () { 87 | return this.html` 88 |
89 |
90 | ` 91 | } 92 | 93 | document.body.innerHTML = ` 94 | 95 | ` 96 | 97 | Tonic.add(ComponentFun) 98 | 99 | const div = document.querySelector('div') 100 | t.ok(div, 'a div was created and attached') 101 | }) 102 | 103 | test('render-only component (non-contextual)', async t => { 104 | function ComponentVeryFun (html, props) { 105 | return html` 106 |
107 |
108 | ` 109 | } 110 | 111 | document.body.innerHTML = ` 112 | 113 | ` 114 | 115 | Tonic.add(ComponentVeryFun) 116 | 117 | const div = document.querySelector('div') 118 | t.ok(div, 'a div was created and attached') 119 | t.equal(div.dataset.id, 'okokok') 120 | }) 121 | 122 | test('Tonic escapes text', async t => { 123 | class Comp extends Tonic { 124 | render () { 125 | const userInput = this.props.userInput 126 | return this.html`
${userInput}
` 127 | } 128 | } 129 | const compName = `x-${uuid()}` 130 | Tonic.add(Comp, compName) 131 | 132 | const userInput = '
lol
' 133 | document.body.innerHTML = ` 134 | <${compName} user-input="${userInput}"> 135 | ` 136 | 137 | const divs = document.querySelectorAll('div') 138 | t.equal(divs.length, 1) 139 | const div = divs[0] 140 | t.equal(div.childNodes.length, 1) 141 | t.equal(div.childNodes[0].nodeType, 3) 142 | t.equal(div.innerHTML, '<pre>lol</pre>') 143 | t.equal(div.childNodes[0].data, '
lol
') 144 | }) 145 | 146 | test('Tonic supports array of templates', async t => { 147 | class Comp1 extends Tonic { 148 | render () { 149 | const options = [] 150 | for (const o of ['one', 'two', 'three']) { 151 | options.push(this.html` 152 | 153 | `) 154 | } 155 | 156 | return this.html`` 157 | } 158 | } 159 | const compName = `x-${uuid()}` 160 | Tonic.add(Comp1, compName) 161 | 162 | document.body.innerHTML = `<${compName}>` 163 | const options = document.body.querySelectorAll('option') 164 | t.equal(options.length, 3) 165 | }) 166 | 167 | test('Tonic escapes attribute injection', async t => { 168 | class Comp1 extends Tonic { 169 | render () { 170 | const userInput2 = '" onload="console.log(42)' 171 | const userInput = '">' 172 | const userInput3 = 'a" onmouseover="alert(1)"' 173 | 174 | const input = this.props.input === 'script' 175 | ? userInput 176 | : this.props.input === 'space' 177 | ? userInput3 178 | : userInput2 179 | 180 | if (this.props.spread) { 181 | return this.html` 182 |
183 | ` 184 | } 185 | 186 | if (this.props.quoted) { 187 | return this.html` 188 |
189 | ` 190 | } 191 | 192 | return this.html` 193 |
194 | ` 195 | } 196 | } 197 | const compName = `x-${uuid()}` 198 | Tonic.add(Comp1, compName) 199 | 200 | document.body.innerHTML = ` 201 | <${compName} input="space" quoted="1"> 202 | <${compName} input="space" spread="1"> 203 | 204 | <${compName} input="space"> 205 | <${compName} input="script" quoted="1"> 206 | <${compName} input="script" spread="1"> 207 | <${compName} input="script"> 208 | <${compName} quoted="1"> 209 | <${compName} spread="1"> 210 | 211 | <${compName}> 212 | ` 213 | 214 | const divs = document.querySelectorAll('div') 215 | t.equal(divs.length, 9) 216 | for (let i = 0; i < divs.length; i++) { 217 | const div = divs[i] 218 | t.equal(div.childNodes.length, 0) 219 | t.equal(div.hasAttribute('onmouseover'), i === 2) 220 | t.equal(div.hasAttribute('onload'), i === 8) 221 | } 222 | }) 223 | 224 | test('attach to dom with shadow', async t => { 225 | Tonic.add(class ShadowComponent extends Tonic { 226 | constructor (o) { 227 | super(o) 228 | this.attachShadow({ mode: 'open' }) 229 | } 230 | 231 | render () { 232 | return this.html` 233 |
234 | 235 |
236 | ` 237 | } 238 | }) 239 | 240 | document.body.innerHTML = ` 241 | 242 | ` 243 | 244 | const c = document.querySelector('shadow-component') 245 | const el = document.querySelector('div') 246 | t.ok(!el, 'no div found in document') 247 | const div = c.shadowRoot.querySelector('div') 248 | t.ok(div, 'a div was created and attached to the shadow root') 249 | t.ok(div.hasAttribute('num'), 'attributes added correctly') 250 | t.ok(div.hasAttribute('str'), 'attributes added correctly') 251 | }) 252 | 253 | test('pass props', async t => { 254 | Tonic.add(class ComponentBB extends Tonic { 255 | render () { 256 | return this.html`
${this.props.data[0].foo}
` 257 | } 258 | }) 259 | 260 | Tonic.add(class ComponentB extends Tonic { 261 | connected () { 262 | this.setAttribute('id', this.props.id) 263 | t.equal(this.props.disabled, '', 'disabled property was found') 264 | t.equal(this.props.empty, '', 'empty property was found') 265 | t.ok(this.props.testItem, 'automatically camelcase props') 266 | } 267 | 268 | render () { 269 | const test = [ 270 | { foo: 'hello, world' } 271 | ] 272 | 273 | return this.html` 274 | 'hello, world'} 279 | set=${new Set(['foo'])} 280 | map=${new Map([['bar', 'bar']])} 281 | weakmap=${new WeakMap([[document, 'baz']])}> 282 | 283 | ` 284 | } 285 | }) 286 | 287 | document.body.innerHTML = ` 288 | 293 | 294 | ` 295 | 296 | const bb = document.getElementById('y') 297 | { 298 | const props = bb.props 299 | t.equal(props.fn(), 'hello, world', 'passed a function') 300 | t.equal(props.number, 42.42, 'float parsed properly') 301 | t.equal(props.set.has('foo'), true, 'set parsed properly') 302 | t.equal(props.map.get('bar'), 'bar', 'map parsed properly') 303 | t.equal(props.weakmap.get(document), 'baz', 'weak map parsed properly') 304 | } 305 | 306 | const div1 = document.getElementsByTagName('div')[0] 307 | t.equal(div1.textContent, 'hello, world', 'data prop received properly') 308 | 309 | const div2 = document.getElementById('x') 310 | t.ok(div2) 311 | 312 | const props = div2.props 313 | t.equal(props.testItem, 'true', 'correct props') 314 | }) 315 | 316 | test('get element by id and set properties via the api', async t => { 317 | document.body.innerHTML = ` 318 | 319 | ` 320 | 321 | class ComponentC extends Tonic { 322 | willConnect () { 323 | this.setAttribute('id', 'test') 324 | } 325 | 326 | render () { 327 | return this.html`
${String(this.props.number)}
` 328 | } 329 | } 330 | 331 | Tonic.add(ComponentC) 332 | 333 | { 334 | const div = document.getElementById('test') 335 | t.ok(div, 'a component was found by its id') 336 | t.equal(div.textContent, '1', 'initial value is set by props') 337 | t.ok(div.reRender, 'a component has the reRender method') 338 | } 339 | 340 | const div = document.getElementById('test') 341 | div.reRender({ number: 2 }) 342 | 343 | await sleep(1) 344 | t.equal(div.textContent, '2', 'the value was changed by reRender') 345 | }) 346 | 347 | test('inheritance and super.render()', async t => { 348 | class Stuff extends Tonic { 349 | render () { 350 | return this.html`
nice stuff
` 351 | } 352 | } 353 | 354 | class SpecificStuff extends Stuff { 355 | render () { 356 | return this.html` 357 |
358 |
A header
359 | ${super.render()} 360 |
361 | ` 362 | } 363 | } 364 | 365 | const compName = `x-${uuid()}` 366 | Tonic.add(SpecificStuff, compName) 367 | 368 | document.body.innerHTML = ` 369 | <${compName}> 370 | ` 371 | 372 | const divs = document.querySelectorAll('div') 373 | t.equal(divs.length, 2) 374 | 375 | const first = divs[0] 376 | t.equal(first.childNodes.length, 5) 377 | t.equal(first.childNodes[1].tagName, 'HEADER') 378 | t.equal(first.childNodes[3].tagName, 'DIV') 379 | t.equal(first.childNodes[3].textContent, 'nice stuff') 380 | }) 381 | 382 | test('Tonic#html returns raw string', async t => { 383 | class Stuff extends Tonic { 384 | render () { 385 | return this.html`
nice stuff
` 386 | } 387 | } 388 | 389 | class SpecificStuff extends Stuff { 390 | render () { 391 | return this.html` 392 |
393 |
A header
394 | ${super.render()} 395 |
396 | ` 397 | } 398 | } 399 | 400 | const compName = `x-${uuid()}` 401 | Tonic.add(SpecificStuff, compName) 402 | 403 | document.body.innerHTML = ` 404 | <${compName}> 405 | ` 406 | 407 | const divs = document.querySelectorAll('div') 408 | t.equal(divs.length, 2) 409 | 410 | const first = divs[0] 411 | t.equal(first.childNodes.length, 5) 412 | t.equal(first.childNodes[1].tagName, 'HEADER') 413 | t.equal(first.childNodes[3].tagName, 'DIV') 414 | t.equal(first.childNodes[3].textContent, 'nice stuff') 415 | }) 416 | 417 | test('construct from api', async t => { 418 | document.body.innerHTML = '' 419 | 420 | class ComponentD extends Tonic { 421 | render () { 422 | return this.html`
` 423 | } 424 | } 425 | 426 | Tonic.add(ComponentD) 427 | const d = new ComponentD() 428 | document.body.appendChild(d) 429 | 430 | d.reRender({ number: 3 }) 431 | 432 | await sleep(1) 433 | const div1 = document.body.querySelector('div') 434 | t.equal(div1.getAttribute('number'), '3', 'attribute was set in component') 435 | 436 | d.reRender({ number: 6 }) 437 | 438 | await sleep(1) 439 | const div2 = document.body.querySelector('div') 440 | t.equal(div2.getAttribute('number'), '6', 'attribute was set in component') 441 | }) 442 | 443 | test('stylesheets and inline styles', async t => { 444 | document.body.innerHTML = ` 445 | 446 | ` 447 | 448 | class ComponentF extends Tonic { 449 | stylesheet () { 450 | return 'component-f div { color: red; }' 451 | } 452 | 453 | styles () { 454 | return { 455 | foo: { 456 | color: 'red' 457 | }, 458 | bar: { 459 | backgroundColor: 'red' 460 | } 461 | } 462 | } 463 | 464 | render () { 465 | return this.html`
` 466 | } 467 | } 468 | 469 | Tonic.add(ComponentF) 470 | 471 | const expected = 'component-f div { color: red; }' 472 | const style = document.querySelector('component-f style') 473 | t.equal(style.textContent, expected, 'style was prefixed') 474 | const div = document.querySelector('component-f div') 475 | const computed = window.getComputedStyle(div) 476 | t.equal(computed.color, 'rgb(255, 0, 0)', 'inline style was set') 477 | t.equal(computed.backgroundColor, 'rgb(255, 0, 0)', 'inline style was set') 478 | }) 479 | 480 | test('static stylesheet', async t => { 481 | document.body.innerHTML = ` 482 | 483 | 484 | ` 485 | 486 | class ComponentStaticStyles extends Tonic { 487 | static stylesheet () { 488 | return 'component-static-styles div { color: red; }' 489 | } 490 | 491 | render () { 492 | return this.html`
RED
` 493 | } 494 | } 495 | 496 | Tonic.add(ComponentStaticStyles) 497 | 498 | const style = document.head.querySelector('style') 499 | t.ok(style, 'has a style tag') 500 | const div = document.querySelector('component-static-styles div') 501 | const computed = window.getComputedStyle(div) 502 | t.equal(computed.color, 'rgb(255, 0, 0)', 'inline style was set') 503 | }) 504 | 505 | test('component composition', async t => { 506 | document.body.innerHTML = ` 507 | A Few 508 | 509 | Noisy 510 | 511 | Text Nodes 512 | ` 513 | 514 | class XFoo extends Tonic { 515 | render () { 516 | return this.html`
` 517 | } 518 | } 519 | 520 | class XBar extends Tonic { 521 | render () { 522 | return this.html` 523 |
524 | 525 | 526 |
527 | ` 528 | } 529 | } 530 | 531 | Tonic.add(XFoo) 532 | Tonic.add(XBar) 533 | 534 | t.equal(document.body.querySelectorAll('.bar').length, 2, 'two bar divs') 535 | t.equal(document.body.querySelectorAll('.foo').length, 4, 'four foo divs') 536 | }) 537 | 538 | test('sync lifecycle events', async t => { 539 | document.body.innerHTML = '' 540 | let calledBazzCtor 541 | let disconnectedBazz 542 | let calledQuxxCtor 543 | 544 | class XBazz extends Tonic { 545 | constructor (p) { 546 | super(p) 547 | calledBazzCtor = true 548 | } 549 | 550 | disconnected () { 551 | disconnectedBazz = true 552 | } 553 | 554 | render () { 555 | return this.html`
` 556 | } 557 | } 558 | 559 | class XQuxx extends Tonic { 560 | constructor (p) { 561 | super(p) 562 | calledQuxxCtor = true 563 | } 564 | 565 | willConnect () { 566 | const expectedRE = /<\/x-quxx>/ 567 | t.ok(true, 'willConnect event fired') 568 | t.ok(expectedRE.test(document.body.innerHTML), 'nothing added yet') 569 | } 570 | 571 | connected () { 572 | t.ok(true, 'connected event fired') 573 | const expectedRE = /
<\/div><\/x-bazz><\/div><\/x-quxx>/ 574 | t.ok(expectedRE.test(document.body.innerHTML), 'rendered') 575 | } 576 | 577 | render () { 578 | t.ok(true, 'render event fired') 579 | return this.html`
` 580 | } 581 | } 582 | 583 | Tonic.add(XBazz) 584 | Tonic.add(XQuxx) 585 | const q = document.querySelector('x-quxx') 586 | q.reRender({}) 587 | const refsLength = Tonic._refIds.length 588 | 589 | // once again to overwrite the old instances 590 | q.reRender({}) 591 | t.equal(Tonic._refIds.length, refsLength, 'Cleanup, refs correct count') 592 | 593 | // once again to check that the refs length is the same 594 | q.reRender({}) 595 | t.equal(Tonic._refIds.length, refsLength, 'Cleanup, refs still correct count') 596 | 597 | await sleep(0) 598 | 599 | t.ok(calledBazzCtor, 'calling bazz ctor') 600 | t.ok(calledQuxxCtor, 'calling quxx ctor') 601 | t.ok(disconnectedBazz, 'disconnected event fired') 602 | }) 603 | 604 | test('async lifecycle events', async t => { 605 | let bar 606 | document.body.innerHTML = '' 607 | 608 | class AsyncF extends Tonic { 609 | connected () { 610 | bar = this.querySelector('.bar') 611 | } 612 | 613 | async render () { 614 | return this.html`
` 615 | } 616 | } 617 | 618 | Tonic.add(AsyncF) 619 | 620 | await sleep(10) 621 | t.ok(bar, 'body was ready') 622 | }) 623 | 624 | test('async-generator lifecycle events', async t => { 625 | let bar 626 | document.body.innerHTML = '' 627 | 628 | class AsyncG extends Tonic { 629 | connected () { 630 | bar = this.querySelector('.bar') 631 | } 632 | 633 | async * render () { 634 | yield 'loading...' 635 | yield 'something else....' 636 | return this.html`
` 637 | } 638 | } 639 | 640 | Tonic.add(AsyncG) 641 | 642 | await sleep(10) 643 | t.ok(bar, 'body was ready') 644 | }) 645 | 646 | test('compose sugar (this.children)', async t => { 647 | class ComponentG extends Tonic { 648 | render () { 649 | return this.html`
${this.children}
` 650 | } 651 | } 652 | 653 | class ComponentH extends Tonic { 654 | render () { 655 | return this.html`
${this.props.value}
` 656 | } 657 | } 658 | 659 | document.body.innerHTML = ` 660 | 661 | 662 | 663 | ` 664 | 665 | Tonic.add(ComponentG) 666 | Tonic.add(ComponentH) 667 | 668 | const g = document.querySelector('component-g') 669 | const children = g.querySelectorAll('.child') 670 | t.equal(children.length, 1, 'child element was added') 671 | t.equal(children[0].innerHTML, 'x') 672 | 673 | const h = document.querySelector('component-h') 674 | 675 | h.reRender({ 676 | value: 'y' 677 | }) 678 | 679 | await sleep(1) 680 | const childrenAfterSetProps = g.querySelectorAll('.child') 681 | t.equal(childrenAfterSetProps.length, 1, 'child element was replaced') 682 | t.equal(childrenAfterSetProps[0].innerHTML, 'y') 683 | }) 684 | 685 | test('ensure registration order does not affect rendering', async t => { 686 | class ComposeA extends Tonic { 687 | render () { 688 | return this.html` 689 |
690 | ${this.children} 691 |
692 | ` 693 | } 694 | } 695 | 696 | class ComposeB extends Tonic { 697 | render () { 698 | return this.html` 699 | 702 | ` 703 | } 704 | } 705 | 706 | document.body.innerHTML = ` 707 | 708 | 709 | 710 | 711 | 712 | 713 | 714 | ` 715 | 716 | Tonic.add(ComposeB) 717 | Tonic.add(ComposeA) 718 | 719 | const select = document.querySelectorAll('.a select') 720 | t.equal(select.length, 1, 'there is only one select') 721 | t.equal(select[0].children.length, 3, 'there are 3 options') 722 | }) 723 | 724 | test('check that composed elements use (and re-use) their initial innerHTML correctly', async t => { 725 | class ComponentI extends Tonic { 726 | render () { 727 | return this.html`
728 | 729 | 730 | 731 | 732 |
` 733 | } 734 | } 735 | 736 | class ComponentJ extends Tonic { 737 | render () { 738 | return this.html`
${this.children}
` 739 | } 740 | } 741 | 742 | class ComponentK extends Tonic { 743 | render () { 744 | return this.html`
${this.props.value}
` 745 | } 746 | } 747 | 748 | document.body.innerHTML = ` 749 | 750 | 751 | ` 752 | 753 | Tonic.add(ComponentJ) 754 | Tonic.add(ComponentK) 755 | Tonic.add(ComponentI) 756 | 757 | t.comment('Uses init() instead of ') 758 | 759 | const i = document.querySelector('component-i') 760 | const kTags = i.getElementsByTagName('component-k') 761 | t.equal(kTags.length, 1) 762 | 763 | const kClasses = i.querySelectorAll('.k') 764 | t.equal(kClasses.length, 1) 765 | 766 | const kText = kClasses[0].textContent 767 | t.equal(kText, 'x', 'The text of the inner-most child was rendered correctly') 768 | 769 | i.reRender({ 770 | value: 1 771 | }) 772 | 773 | await sleep(1) 774 | const kTagsAfterSetProps = i.getElementsByTagName('component-k') 775 | t.equal(kTagsAfterSetProps.length, 1, 'correct number of components rendered') 776 | 777 | const kClassesAfterSetProps = i.querySelectorAll('.k') 778 | t.equal(kClassesAfterSetProps.length, 1, 'correct number of elements rendered') 779 | const kTextAfterSetProps = kClassesAfterSetProps[0].textContent 780 | t.equal(kTextAfterSetProps, '1', 'The text of the inner-most child was rendered correctly') 781 | }) 782 | 783 | test('mixed order declaration', async t => { 784 | class AppXx extends Tonic { 785 | render () { 786 | return this.html`
${this.children}
` 787 | } 788 | } 789 | 790 | class ComponentAx extends Tonic { 791 | render () { 792 | return this.html`
A
` 793 | } 794 | } 795 | 796 | class ComponentBx extends Tonic { 797 | render () { 798 | return this.html`
${this.children}
` 799 | } 800 | } 801 | 802 | class ComponentCx extends Tonic { 803 | render () { 804 | return this.html`
${this.children}
` 805 | } 806 | } 807 | 808 | class ComponentDx extends Tonic { 809 | render () { 810 | return this.html`
D
` 811 | } 812 | } 813 | 814 | document.body.innerHTML = ` 815 | 816 | 817 | 818 | 819 | 820 | 821 | 822 | 823 | 824 | 825 | 826 | ` 827 | 828 | Tonic.add(ComponentDx) 829 | Tonic.add(ComponentAx) 830 | Tonic.add(ComponentCx) 831 | Tonic.add(AppXx) 832 | Tonic.add(ComponentBx) 833 | 834 | { 835 | const div = document.querySelector('.app') 836 | t.ok(div, 'a div was created and attached') 837 | } 838 | 839 | { 840 | const div = document.querySelector('body .app .a') 841 | t.ok(div, 'a div was created and attached') 842 | } 843 | 844 | { 845 | const div = document.querySelector('body .app .b') 846 | t.ok(div, 'a div was created and attached') 847 | } 848 | 849 | { 850 | const div = document.querySelector('body .app .b .c') 851 | t.ok(div, 'a div was created and attached') 852 | } 853 | 854 | { 855 | const div = document.querySelector('body .app .b .c .d') 856 | t.ok(div, 'a div was created and attached') 857 | } 858 | }) 859 | 860 | test('spread props', async t => { 861 | class SpreadComponent extends Tonic { 862 | render () { 863 | return this.html` 864 |
865 | ` 866 | } 867 | } 868 | 869 | class AppContainer extends Tonic { 870 | render () { 871 | const o = { 872 | a: 'testing', 873 | b: 2.2, 874 | FooBar: '"ok"' 875 | } 876 | 877 | const el = document.querySelector('#el').attributes 878 | 879 | return this.html` 880 | 881 | 882 | 883 |
884 |
885 | 886 | 887 | ` 888 | } 889 | } 890 | 891 | document.body.innerHTML = ` 892 | 893 |
894 | ` 895 | 896 | Tonic.add(AppContainer) 897 | Tonic.add(SpreadComponent) 898 | 899 | const component = document.querySelector('spread-component') 900 | t.equal(component.getAttribute('a'), 'testing') 901 | t.equal(component.getAttribute('b'), '2.2') 902 | t.equal(component.getAttribute('foo-bar'), '"ok"') 903 | const div = document.querySelector('div:first-of-type') 904 | const span = document.querySelector('span:first-of-type') 905 | t.equal(div.attributes.length, 3, 'div also got expanded attributes') 906 | t.equal(span.attributes.length, 4, 'span got all attributes from div#el') 907 | }) 908 | 909 | test('async render', async t => { 910 | class AsyncRender extends Tonic { 911 | async getSomeData () { 912 | await sleep(100) 913 | return 'Some Data' 914 | } 915 | 916 | async render () { 917 | const value = await this.getSomeData() 918 | return this.html` 919 |

${value}

920 | ` 921 | } 922 | } 923 | 924 | Tonic.add(AsyncRender) 925 | 926 | document.body.innerHTML = ` 927 | 928 | ` 929 | 930 | let ar = document.body.querySelector('async-render') 931 | t.equal(ar.innerHTML, '') 932 | 933 | await sleep(200) 934 | 935 | ar = document.body.querySelector('async-render') 936 | t.equal(ar.innerHTML.trim(), '

Some Data

') 937 | }) 938 | 939 | test('async generator render', async t => { 940 | class AsyncGeneratorRender extends Tonic { 941 | async * render () { 942 | yield 'X' 943 | 944 | await sleep(100) 945 | 946 | return 'Y' 947 | } 948 | } 949 | 950 | Tonic.add(AsyncGeneratorRender) 951 | 952 | document.body.innerHTML = ` 953 | 954 | 955 | ` 956 | 957 | await sleep(10) 958 | 959 | let ar = document.body.querySelector('async-generator-render') 960 | t.equal(ar.innerHTML, 'X') 961 | 962 | await sleep(200) 963 | 964 | ar = document.body.querySelector('async-generator-render') 965 | t.equal(ar.innerHTML, 'Y') 966 | }) 967 | 968 | test('pass in references to children', async t => { 969 | const cName = `x-${uuid()}` 970 | const dName = `x-${uuid()}` 971 | 972 | class DividerComponent extends Tonic { 973 | willConnect () { 974 | this.left = this.querySelector('.left') 975 | this.right = this.querySelector('.right') 976 | } 977 | 978 | render () { 979 | return this.html` 980 | ${this.left}
981 | ${this.right} 982 | ` 983 | } 984 | } 985 | Tonic.add(DividerComponent, cName) 986 | 987 | class TextComp extends Tonic { 988 | render () { 989 | return this.html`${this.props.text}` 990 | } 991 | } 992 | Tonic.add(TextComp, dName) 993 | 994 | document.body.innerHTML = ` 995 | <${cName}> 996 |
left
997 | <${dName} class="right" text="right"> 998 | 999 | ` 1000 | 1001 | const pElem = document.querySelector(cName) 1002 | 1003 | const first = pElem.children[0] 1004 | t.ok(first) 1005 | t.equal(first.tagName, 'DIV') 1006 | t.equal(first.className, 'left') 1007 | t.equal(first.innerHTML, 'left') 1008 | 1009 | const second = pElem.children[1] 1010 | t.ok(second) 1011 | t.equal(second.tagName, 'BR') 1012 | 1013 | const third = pElem.children[2] 1014 | t.ok(third) 1015 | t.equal(third.tagName, dName.toUpperCase()) 1016 | t.equal(third.className, 'right') 1017 | t.equal(third.innerHTML, 'right') 1018 | }) 1019 | 1020 | test('pass comp as ref in props', async t => { 1021 | const pName = `x-${uuid()}` 1022 | const cName = `x-${uuid()}` 1023 | 1024 | class ParentComponent extends Tonic { 1025 | constructor (o) { 1026 | super(o) 1027 | 1028 | this.name = 'hello' 1029 | } 1030 | 1031 | render () { 1032 | return this.html` 1033 |
1034 | <${cName} ref=${this}> 1035 |
1036 | ` 1037 | } 1038 | } 1039 | 1040 | class ChildComponent extends Tonic { 1041 | render () { 1042 | return this.html` 1043 |
${this.props.ref.name}
1044 | ` 1045 | } 1046 | } 1047 | 1048 | Tonic.add(ParentComponent, pName) 1049 | Tonic.add(ChildComponent, cName) 1050 | 1051 | document.body.innerHTML = `<${pName}>hello
') 1060 | }) 1061 | 1062 | test('default props', async t => { 1063 | class InstanceProps extends Tonic { 1064 | constructor () { 1065 | super() 1066 | this.props = { num: 100 } 1067 | } 1068 | 1069 | render () { 1070 | return this.html`
${JSON.stringify(this.props)}
` 1071 | } 1072 | } 1073 | 1074 | Tonic.add(InstanceProps) 1075 | 1076 | document.body.innerHTML = ` 1077 | 1078 | 1079 | ` 1080 | 1081 | const actual = document.body.innerHTML.trim() 1082 | 1083 | const expectedRE = /
{"num":100,"str":"0x"}<\/div><\/instance-props>/ 1084 | 1085 | t.ok(expectedRE.test(actual), 'elements match') 1086 | }) 1087 | 1088 | test('Tonic comp with null prop', async t => { 1089 | class InnerComp extends Tonic { 1090 | render () { 1091 | return this.html`
${String(this.props.foo)}
` 1092 | } 1093 | } 1094 | const innerName = `x-${uuid()}` 1095 | Tonic.add(InnerComp, innerName) 1096 | 1097 | class OuterComp extends Tonic { 1098 | render () { 1099 | return this.html`<${innerName} foo=${null}>` 1100 | } 1101 | } 1102 | const outerName = `x-${uuid()}` 1103 | Tonic.add(OuterComp, outerName) 1104 | 1105 | document.body.innerHTML = `<${outerName}>` 1106 | 1107 | const div = document.body.querySelector('div') 1108 | t.ok(div) 1109 | 1110 | t.equal(div.textContent, 'null') 1111 | }) 1112 | 1113 | test('re-render nested component', async t => { 1114 | const pName = `x-${uuid()}` 1115 | const cName = `x-${uuid()}` 1116 | class ParentComponent extends Tonic { 1117 | render () { 1118 | const message = this.props.message 1119 | return this.html` 1120 |
1121 | <${cName} id="persist" message="${message}"> 1122 |
1123 | ` 1124 | } 1125 | } 1126 | 1127 | class ChildStateComponent extends Tonic { 1128 | updateText (newText) { 1129 | this.state.text = newText 1130 | this.reRender() 1131 | } 1132 | 1133 | render () { 1134 | const message = this.props.message 1135 | const text = this.state.text || '' 1136 | 1137 | return this.html` 1138 |
1139 | 1140 | 1141 |
1142 | ` 1143 | } 1144 | } 1145 | 1146 | Tonic.add(ParentComponent, pName) 1147 | Tonic.add(ChildStateComponent, cName) 1148 | 1149 | document.body.innerHTML = ` 1150 | <${pName} message="initial"> 1151 | ` 1152 | 1153 | const pElem = document.querySelector(pName) 1154 | t.ok(pElem) 1155 | 1156 | const label = pElem.querySelector('label') 1157 | t.equal(label.textContent, 'initial') 1158 | 1159 | const input = pElem.querySelector('input') 1160 | t.equal(input.value, '') 1161 | 1162 | const cElem = pElem.querySelector(cName) 1163 | cElem.updateText('new text') 1164 | 1165 | async function onUpdate () { 1166 | const label = pElem.querySelector('label') 1167 | t.equal(label.textContent, 'initial') 1168 | 1169 | const input = pElem.querySelector('input') 1170 | t.equal(input.value, 'new text') 1171 | 1172 | pElem.reRender({ 1173 | message: 'new message' 1174 | }) 1175 | } 1176 | 1177 | function onReRender () { 1178 | const label = pElem.querySelector('label') 1179 | t.equal(label.textContent, 'new message') 1180 | 1181 | const input = pElem.querySelector('input') 1182 | t.equal(input.value, 'new text') 1183 | } 1184 | 1185 | await sleep(1) 1186 | await onUpdate() 1187 | await sleep(1) 1188 | await onReRender() 1189 | }) 1190 | 1191 | test('async rendering component', async t => { 1192 | const cName = `x-${uuid()}` 1193 | class AsyncComponent extends Tonic { 1194 | async render () { 1195 | await sleep(100) 1196 | 1197 | return this.html`
${this.props.text}
` 1198 | } 1199 | } 1200 | Tonic.add(AsyncComponent, cName) 1201 | document.body.innerHTML = `<${cName}>` 1202 | 1203 | const cElem = document.querySelector(cName) 1204 | t.ok(cElem) 1205 | t.equal(cElem.textContent, '') 1206 | 1207 | cElem.reRender({ text: 'new text' }) 1208 | t.equal(cElem.textContent, '') 1209 | 1210 | await cElem.reRender({ text: 'new text2' }) 1211 | t.equal(cElem.textContent, 'new text2') 1212 | }) 1213 | 1214 | test('alternating component', async t => { 1215 | const cName = `x-${uuid()}` 1216 | const pName = `x-${uuid()}` 1217 | 1218 | class ParentComponent extends Tonic { 1219 | render () { 1220 | return this.html` 1221 | <${cName} id="alternating"> 1222 |
Child Text
1223 | Span Text 1224 | Raw Text Node 1225 | 1226 | ` 1227 | } 1228 | } 1229 | Tonic.add(ParentComponent, pName) 1230 | 1231 | class AlternatingComponent extends Tonic { 1232 | constructor () { 1233 | super() 1234 | 1235 | this.state = { 1236 | renderCount: 0, 1237 | ...this.state 1238 | } 1239 | } 1240 | 1241 | render () { 1242 | this.state.renderCount++ 1243 | if (this.state.renderCount % 2) { 1244 | return this.html` 1245 |
New content
1246 | ` 1247 | } else { 1248 | return this.html`${this.nodes}` 1249 | } 1250 | } 1251 | } 1252 | Tonic.add(AlternatingComponent, cName) 1253 | 1254 | document.body.innerHTML = `<${pName}>` 1255 | 1256 | const pElem = document.querySelector(pName) 1257 | t.ok(pElem) 1258 | 1259 | let cElem = document.querySelector(cName) 1260 | t.ok(cElem) 1261 | 1262 | t.equal(cElem.children.length, 1) 1263 | t.equal(cElem.children[0].textContent, 'New content') 1264 | 1265 | await pElem.reRender() 1266 | 1267 | cElem = document.querySelector(cName) 1268 | t.ok(cElem) 1269 | 1270 | t.equal(cElem.children.length, 2) 1271 | t.equal(cElem.children[0].textContent, 'Child Text') 1272 | t.equal(cElem.children[1].textContent, 'Span Text') 1273 | 1274 | t.equal(cElem.childNodes.length, 5) 1275 | t.equal(cElem.childNodes[4].data.trim(), 'Raw Text Node') 1276 | 1277 | await pElem.reRender() 1278 | 1279 | cElem = document.querySelector(cName) 1280 | t.equal(cElem.children.length, 1) 1281 | t.equal(cElem.children[0].textContent, 'New content') 1282 | 1283 | await pElem.reRender() 1284 | 1285 | cElem = document.querySelector(cName) 1286 | t.equal(cElem.children.length, 2) 1287 | t.equal(cElem.children[0].textContent, 'Child Text') 1288 | t.equal(cElem.children[1].textContent, 'Span Text') 1289 | 1290 | const child1Ref = cElem.children[0] 1291 | const child2Ref = cElem.children[1] 1292 | 1293 | await cElem.reRender() 1294 | t.equal(cElem.textContent.trim(), 'New content') 1295 | await cElem.reRender() 1296 | t.equal( 1297 | cElem.textContent.trim().replace(/\s+/g, ' '), 1298 | 'Child Text Span Text Raw Text Node' 1299 | ) 1300 | 1301 | t.equal(cElem.children[0], child1Ref) 1302 | t.equal(cElem.children[1], child2Ref) 1303 | }) 1304 | 1305 | test('cleanup, ensure exist', async t => { 1306 | document.body.classList.add('finished') 1307 | }) 1308 | -------------------------------------------------------------------------------- /test/perf/perf.js: -------------------------------------------------------------------------------- 1 | import Benchmark from 'benchmark' 2 | import Tonic from '../../dist/tonic.min.js' 3 | 4 | window.Benchmark = Benchmark 5 | const suite = new Benchmark.Suite() 6 | 7 | class XHello extends Tonic { 8 | render () { 9 | return `

${this.props.message}

` 10 | } 11 | } 12 | 13 | class XApp extends Tonic { 14 | render () { 15 | return this.html` 16 | 17 | 18 | ` 19 | } 20 | } 21 | 22 | document.body.innerHTML = ` 23 | 24 | 25 | ` 26 | 27 | Tonic.add(XHello) 28 | Tonic.add(XApp) 29 | 30 | document.addEventListener('DOMContentLoaded', () => { 31 | const app = document.querySelector('x-app') 32 | const hello = document.querySelector('x-hello') 33 | 34 | suite 35 | .add('re-render a single component', () => { 36 | hello.reRender({ message: Math.random() }) 37 | }) 38 | .add('re-render a hierarchy component', () => { 39 | app.reRender() 40 | }) 41 | .on('cycle', (event) => { 42 | console.log(String(event.target)) 43 | }) 44 | .on('complete', function () { 45 | console.log('done') 46 | }) 47 | .run() 48 | }) 49 | --------------------------------------------------------------------------------