├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierrc ├── Components.md ├── DOMRenderer.md ├── LICENSE ├── Presets.md ├── README.md ├── assets └── rEFui.svg ├── package.json └── src ├── component.js ├── extras ├── cache.js ├── index.js ├── portal.js └── unkeyed.js ├── index.js ├── presets └── browser.js ├── renderer.js ├── renderers ├── dom.js ├── html.js ├── jsx-dev-runtime.js └── jsx-runtime.js ├── signal.js └── utils.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [package.json] 12 | indent_style = space 13 | 14 | [*.yml] 15 | indent_style = space 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/*.js 2 | config/*.js 3 | test/*.js 4 | **/*.json 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "commonjs": true, 6 | "es6": true 7 | }, 8 | "globals": { 9 | "process": true, 10 | "globalThis": true, 11 | }, 12 | "extends": "eslint:recommended", 13 | "parserOptions": { 14 | "ecmaVersion": 2022, 15 | "sourceType": "module" 16 | }, 17 | "rules": { 18 | "accessor-pairs": "error", 19 | "array-bracket-spacing": [ 20 | "error", 21 | "never" 22 | ], 23 | "array-callback-return": "error", 24 | "arrow-body-style": "error", 25 | "arrow-parens": [ 26 | "off", 27 | "as-needed", 28 | { 29 | "requireForBlockBody": true 30 | } 31 | ], 32 | "arrow-spacing": [ 33 | "error", 34 | { 35 | "after": true, 36 | "before": true 37 | } 38 | ], 39 | "block-scoped-var": "error", 40 | "block-spacing": "error", 41 | "brace-style": [ 42 | "error", 43 | "1tbs" 44 | ], 45 | "callback-return": "error", 46 | "camelcase": "warn", 47 | "class-methods-use-this": "error", 48 | "comma-dangle": [ 49 | "error", 50 | "only-multiline" 51 | ], 52 | "comma-spacing": "off", 53 | "comma-style": [ 54 | "error", 55 | "last" 56 | ], 57 | "complexity": "error", 58 | "computed-property-spacing": [ 59 | "error", 60 | "never" 61 | ], 62 | "consistent-return": "off", 63 | "consistent-this": "error", 64 | "curly": "off", 65 | "default-case": "error", 66 | "dot-location": "off", 67 | "dot-notation": "error", 68 | "eol-last": "off", 69 | "eqeqeq": "error", 70 | "func-call-spacing": "error", 71 | "func-names": [ 72 | "error", 73 | "never" 74 | ], 75 | "func-style": [ 76 | "off", 77 | "expression" 78 | ], 79 | "generator-star-spacing": "error", 80 | "global-require": "error", 81 | "guard-for-in": "off", 82 | "handle-callback-err": "error", 83 | "id-blacklist": "error", 84 | "id-length": "off", 85 | "id-match": "error", 86 | "indent": "off", 87 | "init-declarations": "error", 88 | "jsx-quotes": "error", 89 | "key-spacing": "error", 90 | "keyword-spacing": [ 91 | "error", 92 | { 93 | "after": true, 94 | "before": true 95 | } 96 | ], 97 | "line-comment-position": "error", 98 | "linebreak-style": [ 99 | "off" 100 | ], 101 | "lines-around-comment": "error", 102 | "lines-around-directive": "off", 103 | "max-depth": "error", 104 | "max-len": "off", 105 | "max-lines": "off", 106 | "max-nested-callbacks": "error", 107 | "max-params": "error", 108 | "max-statements": "off", 109 | "max-statements-per-line": "error", 110 | "multiline-ternary": "off", 111 | "new-parens": "error", 112 | "newline-after-var": "off", 113 | "newline-before-return": "off", 114 | "newline-per-chained-call": "error", 115 | "no-alert": "error", 116 | "no-array-constructor": "error", 117 | "no-bitwise": "error", 118 | "no-caller": "error", 119 | "no-catch-shadow": "error", 120 | "no-confusing-arrow": "error", 121 | "no-console": "off", 122 | "no-continue": "error", 123 | "no-div-regex": "error", 124 | "no-duplicate-imports": "error", 125 | "no-else-return": "off", 126 | "no-empty-function": "error", 127 | "no-eq-null": "error", 128 | "no-eval": "error", 129 | "no-extend-native": "error", 130 | "no-extra-bind": "error", 131 | "no-extra-label": "error", 132 | "no-extra-parens": "off", 133 | "no-floating-decimal": "error", 134 | "no-global-assign": "error", 135 | "no-implicit-globals": "error", 136 | "no-implied-eval": "error", 137 | "no-inline-comments": "error", 138 | "no-invalid-this": "off", 139 | "no-iterator": "error", 140 | "no-label-var": "error", 141 | "no-labels": "error", 142 | "no-lone-blocks": "error", 143 | "no-lonely-if": "error", 144 | "no-loop-func": "error", 145 | "no-magic-numbers": "off", 146 | "no-mixed-operators": "off", 147 | "no-mixed-requires": "error", 148 | "no-multi-spaces": "error", 149 | "no-multi-str": "error", 150 | "no-multiple-empty-lines": "error", 151 | "no-negated-condition": "error", 152 | "no-nested-ternary": "error", 153 | "no-new": "error", 154 | "no-new-func": "error", 155 | "no-new-object": "error", 156 | "no-new-require": "error", 157 | "no-new-wrappers": "error", 158 | "no-octal-escape": "error", 159 | "no-param-reassign": "off", 160 | "no-path-concat": "error", 161 | "no-plusplus": [ 162 | "error", 163 | { 164 | "allowForLoopAfterthoughts": true 165 | } 166 | ], 167 | "no-process-env": "off", 168 | "no-process-exit": "error", 169 | "no-proto": "error", 170 | "no-prototype-builtins": "error", 171 | "no-restricted-globals": "error", 172 | "no-restricted-imports": "error", 173 | "no-restricted-modules": "error", 174 | "no-restricted-properties": "error", 175 | "no-restricted-syntax": "error", 176 | "no-return-assign": "error", 177 | "no-script-url": "error", 178 | "no-self-compare": "error", 179 | "no-sequences": "error", 180 | "no-shadow": "off", 181 | "no-shadow-restricted-names": "error", 182 | "no-spaced-func": "error", 183 | "no-sync": "error", 184 | "no-tabs": "off", 185 | "no-template-curly-in-string": "error", 186 | "no-ternary": "off", 187 | "no-throw-literal": "error", 188 | "no-trailing-spaces": "error", 189 | "no-undef-init": "error", 190 | "no-undefined": "off", 191 | "no-underscore-dangle": "off", 192 | "no-unmodified-loop-condition": "error", 193 | "no-unneeded-ternary": "error", 194 | "no-unsafe-negation": "error", 195 | "no-unused-expressions": "error", 196 | "no-use-before-define": "off", 197 | "no-useless-call": "error", 198 | "no-useless-computed-key": "error", 199 | "no-useless-concat": "error", 200 | "no-useless-constructor": "error", 201 | "no-useless-escape": "error", 202 | "no-useless-rename": "error", 203 | "no-var": "error", 204 | "no-void": "error", 205 | "no-warning-comments": "error", 206 | "no-whitespace-before-property": "error", 207 | "no-with": "error", 208 | "object-curly-newline": "off", 209 | "object-curly-spacing": [ 210 | "off", 211 | "never" 212 | ], 213 | "object-property-newline": "off", 214 | "object-shorthand": "off", 215 | "one-var": "off", 216 | "one-var-declaration-per-line": "error", 217 | "operator-assignment": "error", 218 | "operator-linebreak": "error", 219 | "padded-blocks": "off", 220 | "prefer-arrow-callback": "error", 221 | "prefer-const": "off", 222 | "prefer-numeric-literals": "error", 223 | "prefer-reflect": "off", 224 | "prefer-rest-params": "error", 225 | "prefer-spread": "error", 226 | "prefer-template": "error", 227 | "quote-props": "off", 228 | "quotes": "off", 229 | "radix": "error", 230 | "require-jsdoc": "off", 231 | "rest-spread-spacing": [ 232 | "error", 233 | "never" 234 | ], 235 | "semi": [ 236 | "warn", 237 | "never" 238 | ], 239 | "semi-spacing": [ 240 | "error", 241 | { 242 | "after": true, 243 | "before": false 244 | } 245 | ], 246 | "sort-imports": "off", 247 | "sort-keys": "off", 248 | "sort-vars": "off", 249 | "space-before-blocks": "error", 250 | "space-before-function-paren": "off", 251 | "space-in-parens": [ 252 | "error", 253 | "never" 254 | ], 255 | "space-infix-ops": "error", 256 | "space-unary-ops": [ 257 | "error", 258 | { 259 | "nonwords": false, 260 | "words": false 261 | } 262 | ], 263 | "spaced-comment": [ 264 | "error", 265 | "always" 266 | ], 267 | "strict": "off", 268 | "symbol-description": "error", 269 | "template-curly-spacing": [ 270 | "error", 271 | "never" 272 | ], 273 | "unicode-bom": [ 274 | "error", 275 | "never" 276 | ], 277 | "valid-jsdoc": "error", 278 | "vars-on-top": "error", 279 | "wrap-iife": "error", 280 | "wrap-regex": "error", 281 | "yield-star-spacing": "error", 282 | "yoda": [ 283 | "error", 284 | "never" 285 | ], 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated files 2 | bin/ 3 | gen/ 4 | 5 | # Log Files 6 | *.log 7 | *.log.* 8 | 9 | # Temp Files 10 | *~ 11 | *.*~ 12 | .fuse_* 13 | yarn.lock 14 | cache/ 15 | temp/ 16 | .DS_Store 17 | 18 | # Project files 19 | dist/* 20 | test/*.dev.js 21 | test/*.dev.js.map 22 | 23 | # node modules 24 | node_modules/ 25 | 26 | # local tools 27 | publish 28 | /out 29 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": true, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "fluid": false 10 | } 11 | -------------------------------------------------------------------------------- /Components.md: -------------------------------------------------------------------------------- 1 | ## Basic Components 2 | 3 | ### If 4 | 5 | Note: `else` prop has higher priority than the second branch 6 | 7 | ```jsx 8 | import { If } from 'refui' 9 | 10 | const App = ({ value }) => { 11 | return (R) => ( 12 | Condition not met!}> 13 | {/*if condition is truthy*/ () => Condition met!} 14 | {/*else*/ () => Condition not met!} 15 | 16 | ) 17 | } 18 | ``` 19 | 20 | ### For 21 | 22 | ```jsx 23 | import { For } from 'refui' 24 | 25 | const App = ({ iterable }) => { 26 | return (R) => ( 27 | {(item) =>
{item.name}
}
28 | // name will not be changed if modified directly on item 29 | ) 30 | } 31 | ``` 32 | 33 | ### Fn 34 | 35 | Note: define return renderers outside of the `Fn` scope can reduce re-renders if condition doesn't change its matched result. 36 | 37 | ```jsx 38 | import { Fn, read } from 'refui' 39 | 40 | const App = ({ condition }) => { 41 | return (R) => ( 42 | 43 | {() => { 44 | switch (read(condition)) { 45 | case 123: { 46 | return () =>
Condition: 123
47 | } 48 | case 456: { 49 | return () =>
Condition: 456
50 | } 51 | default: { 52 | return () =>
Nothing matched!
53 | } 54 | } 55 | }} 56 | 57 | ) 58 | } 59 | ``` 60 | 61 | ### Dynamic 62 | 63 | Just like any ordinary components but the component itself is a variable/signal 64 | 65 | ```jsx 66 | import { signal, Dynamic } from 'refui' 67 | 68 | const App = () => { 69 | const currentComponent = signal('button') 70 | return (R) => ( 71 | { 74 | if (currentComponent.value === 'button') { 75 | currentComponent.value = 'div' 76 | return 77 | } 78 | currentComponent.value = 'button' 79 | }} 80 | > 81 | Click to change tag! 82 | 83 | ) 84 | } 85 | ``` 86 | 87 | ### Async 88 | 89 | Just like any ordinary components but the component is asynchronous 90 | 91 | ```jsx 92 | const AsyncComponent = async ({ apiPath }) => { 93 | const resp = await fetch(apiPath) 94 | const result = await resp.text() 95 | 96 | return (R) => ( 97 |
{result}
98 | ) 99 | } 100 | ``` 101 | 102 | Async components accepts an extra param: `fallback`, which is a render method or a rendered result to be displayed when the component itself isn't ready. Alternativelly, you can use `Async` as a dedicated component: 103 | 104 | ```jsx 105 | import { Async } from 'refui' 106 | import { AsyncComponent } from './async-component.js' 107 | 108 | const App = () => { 109 | const currentComponent = signal('button') 110 | return (R) => ( 111 | Loading...
} /> 112 | ) 113 | } 114 | 115 | const AppAlternative = () => { 116 | const currentComponent = signal('button') 117 | return (R) => ( 118 |
Loading...
} /> 119 | ) 120 | } 121 | ``` 122 | 123 | ## Extra Components 124 | 125 | Extra components are located in the `refui/extras` path. 126 | 127 | ### UnKeyed 128 | 129 | Same as `For`, but the prop itself is a signal. 130 | 131 | ```jsx 132 | import { UnKeyed } from 'refui/extras' 133 | // or 134 | import { UnKeyed } from 'refui/extras/unkeyed.js' 135 | 136 | import { derivedExtract } from 'refui' 137 | 138 | const App = ({ iterable }) => { 139 | return (R) => ( 140 | 141 | {(item) => { 142 | const { name } = derivedExtract(item) 143 | return
{name}
// name will correctly get reactive if it's a signal on item 144 | }} 145 |
146 | ) 147 | } 148 | ``` 149 | 150 | ### Cached 151 | 152 | ### Render 153 | 154 | ### Portal 155 | -------------------------------------------------------------------------------- /DOMRenderer.md: -------------------------------------------------------------------------------- 1 | ## Setting attributes 2 | 3 | Props are default to DOM object properties, setting attributes should add the `attr:` prefix. 4 | 5 | Props containing `-` are considered as attributes as well. You can override the behavior by adding `prop:` prefix to them. 6 | 7 | Boolean values are rendered as toggling the prop on and off. 8 | 9 | Usage: `attr:attribute-to-be-set="value"` 10 | 11 | Example: 12 | ```jsx 13 | <> 14 | 15 |
16 |
17 | 18 | ``` 19 | 20 | ## Event handling 21 | 22 | Usage: `on[-option-moreOptions]:eventName={handler}` 23 | 24 | Examples: 25 | 26 | - Simple click 27 | ```jsx 28 | 29 | ``` 30 | 31 | - Click once 32 | ```jsx 33 | 34 | ``` 35 | 36 | - Passive 37 | ```jsx 38 |
{/* do some time consuming operations */}}>{loooooongContent}
39 | ``` 40 | 41 | - Multiple options 42 | ```jsx 43 |
alert('Clicked!')}>
44 | ``` 45 | 46 | - Get event object 47 | ```jsx 48 | console.log(event.target.value)}/> 49 | ``` 50 | 51 | ## Defaults 52 | 53 | We provide presets for conveinence. 54 | 55 | ### Browser 56 | 57 | - Check [here](Presets.md#browser) 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Yukino Song 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Presets.md: -------------------------------------------------------------------------------- 1 | ## Browser 2 | 3 | - [Source](src/presets/browser.js) 4 | 5 | Presets for browsers, with pre-defined tag namespaces (especially for SVG) and several preset attributes. 6 | 7 | Use with DOM renderer. 8 | 9 | ### Usage 10 | ```js 11 | import { createDOMRenderer } from 'refui/dom' 12 | import { defaults } from 'refui/browser' 13 | 14 | export default const Renderer = createDOMRenderer(defaults) 15 | ``` 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

rEFui

4 | 5 | Pronunced as /ɹiːˈfjuːəl/, refuel 6 | 7 | The JavaScript framework that refuels your UI projects, across web, native, and embedded. 8 | 9 | ## Usage 10 | 11 | ```shell 12 | npm i refui 13 | ``` 14 | 15 | ### Web 16 | 17 | ```jsx 18 | import { signal } from 'refui' 19 | import { createDOMRenderer } from 'refui/dom' 20 | import { defaults } from 'refui/browser' 21 | 22 | const DOMRenderer = createDOMRenderer(defaults) 23 | 24 | const App = () => { 25 | const count = signal(0) 26 | const increment = () => { 27 | count.value += 1 28 | } 29 | 30 | return (R) => ( 31 | <> 32 |

Hello, rEFui

33 | 34 | 35 | ) 36 | } 37 | 38 | DOMRenderer.render(document.body, App) 39 | 40 | ``` 41 | 42 | ### Native 43 | 44 | by using [DOMiNATIVE](https://github.com/SudoMaker/dominative) alongside with [NativeScript](https://nativescript.org/) 45 | 46 | [DEMO](https://stackblitz.com/edit/refui-nativescript?file=app%2Fapp.jsx) 47 | 48 | ```jsx 49 | import { Application } from '@nativescript/core' 50 | import { document } from 'dominative' 51 | import { signal } from 'refui' 52 | import { createDOMRenderer } from 'refui/dom' 53 | 54 | const DOMRenderer = createDOMRenderer({doc: document}) 55 | 56 | const App = () => { 57 | const count = signal(0) 58 | const increment = () => { 59 | count.value += 1 60 | } 61 | return (R) => ( 62 | <> 63 | 64 | 65 | 66 | 67 | 68 | 69 | ) 70 | } 71 | 72 | DOMRenderer.render(document.body, App) 73 | 74 | const create = () => document 75 | 76 | Application.run({ create }) 77 | ``` 78 | 79 | ### Embedded 80 | 81 | by using CheeseDOM alongside with Resonance 82 | 83 | ```jsx 84 | import { document } from 'cheesedom' 85 | import { signal, t } from 'refui' 86 | import { createDOMRenderer } from 'refui/dom' 87 | 88 | const DOMRenderer = createDOMRenderer({doc: document}) 89 | 90 | const App = () => { 91 | const count = signal(0) 92 | const increment = () => { 93 | count.value += 1 94 | } 95 | return (R) => ( 96 | <> 97 | 98 | 101 | 102 | ) 103 | } 104 | 105 | DOMRenderer.render(document, App) 106 | ``` 107 | 108 | ## JSX configurations 109 | 110 | ```js 111 | /** @jsx R.c */ 112 | /** @jsxFrag R.f */ 113 | ``` 114 | 115 | Set these values accordingly to your transpiler configuration, or add the above comments to the top of your JSX file. 116 | 117 | ## Built-in Components 118 | 119 | See [Components](Components.md) 120 | 121 | ## Renderers 122 | 123 | - [DOM](DOMRenderer.md) 124 | 125 | ## Prebuilt version 126 | 127 | You're building your app with a toolchain/compiler/transpiler anyways, so there's no need to provide a prebuilt version. 128 | 129 | ## License 130 | MIT 131 | -------------------------------------------------------------------------------- /assets/rEFui.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "refui", 3 | "version": "0.1.2", 4 | "description": "The JavaScript framework that refuels your UI projects, across web, native, and embedded", 5 | "main": "src/index.js", 6 | "type": "module", 7 | "scripts": {}, 8 | "keywords": [ 9 | "refui", 10 | "refuel", 11 | "ef", 12 | "framework", 13 | "frontend", 14 | "native", 15 | "embedded", 16 | "signal", 17 | "dom", 18 | "ui" 19 | ], 20 | "exports": { 21 | ".": "./src/index.js", 22 | "./dom": "./src/renderers/dom.js", 23 | "./html": "./src/renderers/html.js", 24 | "./jsx-runtime": "./src/renderers/jsx-runtime.js", 25 | "./jsx-dev-runtime": "./src/renderers/jsx-dev-runtime.js", 26 | "./browser": "./src/presets/browser.js", 27 | "./extras": "./src/extras/index.js", 28 | "./renderers/": "./src/renderers/", 29 | "./presets/": "./src/presets/", 30 | "./extras/": "./src/extras/", 31 | "./package.json": "./package.json" 32 | }, 33 | "files": [ 34 | "src" 35 | ], 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/SudoMaker/rEFui.git" 39 | }, 40 | "author": "Yukino Song ", 41 | "license": "MIT", 42 | "bugs": "https://github.com/SudoMaker/rEFui/issues", 43 | "homepage": "https://github.com/SudoMaker/rEFui" 44 | } 45 | -------------------------------------------------------------------------------- /src/component.js: -------------------------------------------------------------------------------- 1 | import { collectDisposers, nextTick, read, peek, watch, onDispose, signal, isSignal } from './signal.js' 2 | import { removeFromArr } from './utils.js' 3 | 4 | const ctxMap = new WeakMap() 5 | 6 | let currentCtx = null 7 | 8 | const expose = (ctx) => { 9 | if (currentCtx) Object.assign(currentCtx.exposed, ctx) 10 | } 11 | 12 | const render = (instance, renderer) => { 13 | const ctx = ctxMap.get(instance) 14 | if (!ctx) return 15 | const { disposers, render: renderComponent } = ctx 16 | if (!renderComponent || typeof renderComponent !== 'function') return renderComponent 17 | 18 | let rendered = null 19 | const _disposers = [] 20 | const newDispose = collectDisposers( 21 | _disposers, 22 | () => { 23 | rendered = renderComponent(renderer) 24 | }, 25 | () => { 26 | removeFromArr(disposers, newDispose) 27 | } 28 | ) 29 | disposers.push(newDispose) 30 | return rendered 31 | } 32 | 33 | const dispose = (instance) => { 34 | const ctx = ctxMap.get(instance) 35 | if (!ctx) return 36 | ctx.dispose() 37 | } 38 | 39 | const getCurrentSelf = () => currentCtx && currentCtx.self 40 | 41 | const Fn = ({ name = 'Fn' }, handler) => { 42 | const disposers = [] 43 | onDispose(() => { 44 | for (let i of disposers) i(true) 45 | disposers.length = 0 46 | }) 47 | 48 | return (R) => { 49 | if (!handler) return 50 | 51 | const fragment = R.createFragment(name) 52 | let currentRender = null 53 | let currentDispose = null 54 | 55 | watch(() => { 56 | const newRender = handler() 57 | if (newRender === currentRender) return 58 | currentRender = newRender 59 | if (currentDispose) currentDispose() 60 | if (newRender) { 61 | let newResult = null 62 | const newDispose = collectDisposers( 63 | [], 64 | () => { 65 | if (typeof newRender === 'function') { 66 | newResult = newRender(R) 67 | } else { 68 | newResult = newRender 69 | } 70 | if (newResult) { 71 | if (!R.isNode(newResult)) newResult = R.createTextNode(newResult) 72 | R.appendNode(fragment, newResult) 73 | } 74 | }, 75 | () => { 76 | removeFromArr(disposers, newDispose) 77 | if (newResult) { 78 | nextTick(() => R.removeNode(newResult)) 79 | } 80 | } 81 | ) 82 | disposers.push(newDispose) 83 | currentDispose = newDispose 84 | } 85 | }) 86 | 87 | return fragment 88 | } 89 | } 90 | 91 | const For = ({ name = 'For', entries, track, indexed }, item) => { 92 | let currentData = [] 93 | 94 | let kv = track && new Map() 95 | let ks = indexed && new Map() 96 | let nodeCache = new Map() 97 | let disposers = new Map() 98 | 99 | const _clear = () => { 100 | for (let [, _dispose] of disposers) _dispose(true) 101 | nodeCache = new Map() 102 | disposers = new Map() 103 | if (ks) ks = new Map() 104 | } 105 | 106 | const flushKS = () => { 107 | if (ks) { 108 | for (let i = 0; i < currentData.length; i++) { 109 | const sig = ks.get(currentData[i]) 110 | sig.value = i 111 | } 112 | } 113 | } 114 | 115 | const getItem = itemKey => (kv ? kv.get(itemKey) : itemKey) 116 | const remove = (itemKey) => { 117 | const itemData = getItem(itemKey) 118 | removeFromArr(peek(entries), itemData) 119 | entries.trigger() 120 | } 121 | const clear = () => { 122 | if (!currentData.length) return 123 | _clear() 124 | if (kv) kv = new Map() 125 | currentData = [] 126 | if (entries.value.length) entries.value = [] 127 | } 128 | 129 | onDispose(_clear) 130 | 131 | expose({ 132 | getItem, 133 | remove, 134 | clear 135 | }) 136 | 137 | return (R) => { 138 | const fragment = R.createFragment(name) 139 | 140 | const getItemNode = (itemKey) => { 141 | let node = nodeCache.get(itemKey) 142 | if (!node) { 143 | const newDataItem = kv ? kv.get(itemKey) : itemKey 144 | let idxSig = ks ? ks.get(itemKey) : 0 145 | if (ks && !idxSig) { 146 | idxSig = signal(0) 147 | ks.set(itemKey, idxSig) 148 | } 149 | const dispose = collectDisposers( 150 | [], 151 | () => { 152 | node = item(newDataItem, idxSig, R) 153 | nodeCache.set(itemKey, node) 154 | }, 155 | (batch) => { 156 | if (!batch) { 157 | nodeCache.delete(itemKey) 158 | disposers.delete(itemKey) 159 | if (ks) ks.delete(itemKey) 160 | if (kv) kv.delete(itemKey) 161 | } 162 | if (node) R.removeNode(node) 163 | } 164 | ) 165 | disposers.set(itemKey, dispose) 166 | } 167 | return node 168 | } 169 | 170 | // eslint-disable-next-line complexity 171 | watch(() => { 172 | /* eslint-disable max-depth */ 173 | const data = read(entries) 174 | if (!data || !data.length) return clear() 175 | 176 | let oldData = currentData 177 | if (track) { 178 | kv = new Map() 179 | const key = read(track) 180 | currentData = data.map((i) => { 181 | const itemKey = i[key] 182 | kv.set(itemKey, i) 183 | return itemKey 184 | }) 185 | } else currentData = [...data] 186 | 187 | let newData = null 188 | 189 | if (oldData.length) { 190 | const obsoleteDataKeys = [...new Set([...currentData, ...oldData])].slice(currentData.length) 191 | 192 | if (obsoleteDataKeys.length === oldData.length) { 193 | _clear() 194 | newData = currentData 195 | } else { 196 | if (obsoleteDataKeys.length) { 197 | for (let oldItemKey of obsoleteDataKeys) { 198 | disposers.get(oldItemKey)() 199 | removeFromArr(oldData, oldItemKey) 200 | } 201 | } 202 | 203 | const newDataKeys = [...new Set([...oldData, ...currentData])].slice(oldData.length) 204 | const hasNewKeys = !!newDataKeys.length 205 | 206 | let newDataCursor = 0 207 | 208 | while (newDataCursor < currentData.length) { 209 | 210 | if (!oldData.length) { 211 | if (newDataCursor) newData = currentData.slice(newDataCursor) 212 | break 213 | } 214 | 215 | const frontSet = [] 216 | const backSet = [] 217 | 218 | let frontChunk = [] 219 | let backChunk = [] 220 | 221 | let prevChunk = frontChunk 222 | 223 | let oldDataCursor = 0 224 | let oldItemKey = oldData[0] 225 | 226 | let newItemKey = currentData[newDataCursor] 227 | 228 | while (oldDataCursor < oldData.length) { 229 | const isNewKey = hasNewKeys && newDataKeys.includes(newItemKey) 230 | if (isNewKey || oldItemKey === newItemKey) { 231 | if (prevChunk !== frontChunk) { 232 | backSet.push(backChunk) 233 | backChunk = [] 234 | prevChunk = frontChunk 235 | } 236 | 237 | frontChunk.push(newItemKey) 238 | 239 | if (isNewKey) { 240 | R.insertBefore(getItemNode(newItemKey), getItemNode(oldItemKey)) 241 | } else { 242 | oldDataCursor += 1 243 | oldItemKey = oldData[oldDataCursor] 244 | } 245 | newDataCursor += 1 246 | newItemKey = currentData[newDataCursor] 247 | } else { 248 | if (prevChunk !== backChunk) { 249 | frontSet.push(frontChunk) 250 | frontChunk = [] 251 | prevChunk = backChunk 252 | } 253 | backChunk.push(oldItemKey) 254 | oldDataCursor += 1 255 | oldItemKey = oldData[oldDataCursor] 256 | } 257 | } 258 | 259 | if (prevChunk === frontChunk) { 260 | frontSet.push(frontChunk) 261 | } 262 | 263 | backSet.push(backChunk) 264 | frontSet.shift() 265 | 266 | for (let i = 0; i < frontSet.length; i++) { 267 | const fChunk = frontSet[i] 268 | const bChunk = backSet[i] 269 | 270 | if (fChunk.length <= bChunk.length) { 271 | const beforeAnchor = getItemNode(bChunk[0]) 272 | backSet[i + 1] = bChunk.concat(backSet[i + 1]) 273 | bChunk.length = 0 274 | 275 | for (let itemKey of fChunk) { 276 | R.insertBefore(getItemNode(itemKey), beforeAnchor) 277 | } 278 | } else if (backSet[i + 1].length) { 279 | const beforeAnchor = getItemNode(backSet[i + 1][0]) 280 | for (let itemKey of bChunk) { 281 | R.insertBefore(getItemNode(itemKey), beforeAnchor) 282 | } 283 | } else { 284 | R.appendNode(fragment, ...bChunk.map(getItemNode)) 285 | } 286 | } 287 | 288 | oldData = [].concat(...backSet) 289 | } 290 | } 291 | } else { 292 | newData = currentData 293 | } 294 | 295 | if (newData) { 296 | for (let newItemKey of newData) { 297 | const node = getItemNode(newItemKey) 298 | if (node) R.appendNode(fragment, node) 299 | } 300 | } 301 | 302 | flushKS() 303 | }) 304 | 305 | return fragment 306 | } 307 | } 308 | 309 | const If = ({ condition, else: otherwise }, trueBranch, falseBranch) => { 310 | const ifNot = otherwise || falseBranch 311 | if (isSignal(condition)) { 312 | return Fn({ name: 'If' }, () => { 313 | if (condition.value) return trueBranch 314 | else return ifNot 315 | }) 316 | } 317 | 318 | if (typeof condition === 'function') { 319 | return Fn({ name: 'If' }, () => { 320 | if (condition()) return trueBranch 321 | else return ifNot 322 | }) 323 | } 324 | 325 | if (condition) return trueBranch 326 | return ifNot 327 | } 328 | 329 | const Dynamic = ({ is, ...props }, ...children) => { 330 | const current = signal(null) 331 | expose({ current }) 332 | return Fn({ name: 'Dynamic' }, () => { 333 | const component = read(is) 334 | if (component) return (R) => R.c(component, { $ref: current, ...props }, ...children) 335 | else current.value = null 336 | }) 337 | } 338 | 339 | const Async = ({ future, fallback }) => { 340 | const component = signal(fallback) 341 | future.then((result) => { 342 | component.value = result 343 | }) 344 | return Fn({ name: 'Async' }, () => { 345 | return component.value 346 | }) 347 | } 348 | 349 | const Render = ({ from }) => (R) => R.c(Fn, { name: 'Render' }, () => { 350 | const instance = read(from) 351 | if (instance !== null && instance !== undefined) return render(instance, R) 352 | }) 353 | 354 | const Component = class Component { 355 | constructor(tpl, props, ...children) { 356 | const ctx = { 357 | exposed: {}, 358 | disposers: [], 359 | render: null, 360 | dispose: null, 361 | self: this 362 | } 363 | 364 | const prevCtx = currentCtx 365 | currentCtx = ctx 366 | 367 | ctx.dispose = collectDisposers(ctx.disposers, () => { 368 | let renderFn = tpl(props, ...children) 369 | if (renderFn && renderFn.then) { 370 | renderFn = Async({future: Promise.resolve(renderFn), fallback: props && props.fallback || null}) 371 | } 372 | ctx.render = renderFn 373 | }) 374 | 375 | currentCtx = prevCtx 376 | 377 | const entries = Object.entries(ctx.exposed) 378 | 379 | if (entries.length) { 380 | Object.defineProperties( 381 | this, 382 | entries.reduce((descriptors, [key, value]) => { 383 | if (isSignal(value)) { 384 | descriptors[key] = { 385 | get: value.get.bind(value), 386 | set: value.set.bind(value), 387 | enumerable: true, 388 | configurable: false 389 | } 390 | } else { 391 | descriptors[key] = { 392 | value, 393 | enumerable: true, 394 | configurable: false 395 | } 396 | } 397 | 398 | return descriptors 399 | }, {}) 400 | ) 401 | } 402 | 403 | ctxMap.set(this, ctx) 404 | } 405 | } 406 | 407 | const createComponent = (tpl, props, ...children) => { 408 | if (props === null || props === undefined) props = {} 409 | const { $ref, ..._props } = props 410 | const component = new Component(tpl, _props, ...children) 411 | if ($ref) $ref.value = component 412 | return component 413 | } 414 | 415 | export { 416 | expose, 417 | render, 418 | dispose, 419 | getCurrentSelf, 420 | Fn, 421 | For, 422 | If, 423 | Dynamic, 424 | Async, 425 | Render, 426 | Component, 427 | createComponent 428 | } 429 | -------------------------------------------------------------------------------- /src/extras/cache.js: -------------------------------------------------------------------------------- 1 | import { signal, untrack, onDispose } from '../signal.js' 2 | import { render, expose, createComponent, For } from '../component.js' 3 | 4 | const createCache = (tpl) => { 5 | let dataArr = [] 6 | const componentsArr = [] 7 | const components = signal(componentsArr) 8 | let componentCache = [] 9 | 10 | const getIndex = handler => dataArr.findIndex(handler) 11 | const add = (...newData) => { 12 | if (!newData.length) return 13 | for (let i of newData) { 14 | let component = componentCache.pop() 15 | if (!component) component = createComponent(tpl, i) 16 | componentsArr.push(component) 17 | component.update(i) 18 | dataArr.push(i) 19 | } 20 | components.trigger() 21 | } 22 | const replace = (newData) => { 23 | let idx = 0 24 | dataArr = newData.slice() 25 | const newDataLength = newData.length 26 | const componentsLength = componentsArr.length 27 | while (idx < newDataLength && idx < componentsLength) { 28 | componentsArr[idx].update(newData[idx]) 29 | idx += 1 30 | } 31 | if (idx < newDataLength) { 32 | add(...newData.slice(idx)) 33 | } else if (idx < componentsLength) { 34 | componentsArr.length = idx 35 | components.trigger() 36 | } 37 | } 38 | const get = idx => dataArr[idx] 39 | const set = (idx, data) => { 40 | const component = componentsArr[idx] 41 | if (component) { 42 | component.update(data) 43 | dataArr[idx] = data 44 | } 45 | } 46 | const del = (idx) => { 47 | const component = componentsArr[idx] 48 | if (component) { 49 | componentCache.push(component) 50 | componentsArr.splice(idx, 1) 51 | dataArr.splice(idx, 1) 52 | components.trigger() 53 | } 54 | } 55 | const clear = () => { 56 | componentCache = componentCache.concat(componentsArr) 57 | componentsArr.length = 0 58 | dataArr.length = 0 59 | components.trigger() 60 | } 61 | const size = () => componentsArr.length 62 | 63 | const dispose = () => { 64 | clear() 65 | for (let i of componentsArr) dispose(i) 66 | } 67 | 68 | onDispose(dispose) 69 | 70 | const Cached = () => (R) => { 71 | const cache = new WeakMap() 72 | expose({ cache }) 73 | return R.c(For, { entries: components }, (row) => { 74 | let node = cache.get(row) 75 | if (!node) { 76 | node = untrack(() => render(row, R)) 77 | cache.set(row, node) 78 | } 79 | return node 80 | }) 81 | } 82 | 83 | return { 84 | getIndex, 85 | add, 86 | replace, 87 | get, 88 | set, 89 | del, 90 | clear, 91 | size, 92 | dispose, 93 | Cached 94 | } 95 | } 96 | 97 | export { createCache } 98 | -------------------------------------------------------------------------------- /src/extras/index.js: -------------------------------------------------------------------------------- 1 | export { createPortal } from './portal.js' 2 | export { createCache } from './cache.js' 3 | export { UnKeyed } from './unkeyed.js' 4 | -------------------------------------------------------------------------------- /src/extras/portal.js: -------------------------------------------------------------------------------- 1 | import { signal, onDispose } from '../signal.js' 2 | import { dispose, getCurrentSelf, For, Fn } from '../component.js' 3 | import { removeFromArr } from '../utils.js' 4 | 5 | const createPortal = () => { 6 | let currentOutlet = null 7 | const nodes = signal([]) 8 | const outletView = R => R.c(For, { entries: nodes }, child => child) 9 | const Inlet = (_, ...children) => ({ normalizeChildren }) => { 10 | const normalizedChildren = normalizeChildren(children) 11 | nodes.peek().push(...normalizedChildren) 12 | nodes.trigger() 13 | onDispose(() => { 14 | const arr = nodes.peek() 15 | for (let i of normalizedChildren) { 16 | removeFromArr(arr, i) 17 | } 18 | nodes.value = [...nodes.peek()] 19 | }) 20 | } 21 | const Outlet = (_, fallback) => { 22 | if (currentOutlet) dispose(currentOutlet) 23 | currentOutlet = getCurrentSelf() 24 | return ({ c }) => c(Fn, null, () => { 25 | if (nodes.value.length) return outletView 26 | return fallback 27 | }) 28 | } 29 | 30 | return [Inlet, Outlet] 31 | } 32 | 33 | export { createPortal } 34 | -------------------------------------------------------------------------------- /src/extras/unkeyed.js: -------------------------------------------------------------------------------- 1 | import { signal, watch, read } from '../signal.js' 2 | import { For } from '../component.js' 3 | 4 | export const UnKeyed = ({ entries, ...args }, item) => { 5 | const rawSigEntries = [] 6 | const sigEntries = signal(rawSigEntries) 7 | 8 | watch(() => { 9 | const rawEntries = read(entries) 10 | const oldLength = rawSigEntries.length 11 | rawSigEntries.length = rawEntries.length 12 | for (let i in rawEntries) { 13 | if (rawSigEntries[i]) rawSigEntries[i].value = rawEntries[i] 14 | else rawSigEntries[i] = signal(rawEntries[i]) 15 | } 16 | 17 | if (oldLength !== rawEntries.length) sigEntries.trigger() 18 | }) 19 | 20 | return (R) => R.c(For, { name: 'UnKeyed', entries: sigEntries, ...args }, item) 21 | } 22 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { computed, merge, tpl } from './signal.js' 2 | 3 | export { computed as $, merge as $$, tpl as t } 4 | 5 | export * from './signal.js' 6 | export * from './renderer.js' 7 | export * from './component.js' 8 | -------------------------------------------------------------------------------- /src/presets/browser.js: -------------------------------------------------------------------------------- 1 | import { nextTick, bind } from '../signal.js' 2 | 3 | const reverseMap = (keyValsMap) => { 4 | const reversed = {} 5 | for (let [key, vals] of Object.entries(keyValsMap)) { 6 | for (let val of vals) { 7 | reversed[val] = key 8 | } 9 | } 10 | return reversed 11 | } 12 | 13 | const prefix = (prefix, keyArr) => Object.fromEntries(keyArr.map((i) => [i, `${prefix}${i}`])) 14 | 15 | export const namespaces = { 16 | xml: 'http://www.w3.org/XML/1998/namespace', 17 | html: 'http://www.w3.org/1999/xhtml', 18 | svg: 'http://www.w3.org/2000/svg', 19 | math: 'http://www.w3.org/1998/Math/MathML', 20 | xlink: 'http://www.w3.org/1999/xlink' 21 | } 22 | 23 | export const tagAliases = {} 24 | 25 | const attributes = ['class', 'style', 'viewBox', 'd', 'tabindex', 'role'] 26 | 27 | const namespaceToTagsMap = { 28 | svg: [ 29 | 'animate', 30 | 'animateMotion', 31 | 'animateTransform', 32 | 'circle', 33 | 'clipPath', 34 | 'defs', 35 | 'desc', 36 | 'discard', 37 | 'ellipse', 38 | 'feBlend', 39 | 'feColorMatrix', 40 | 'feComponentTransfer', 41 | 'feComposite', 42 | 'feConvolveMatrix', 43 | 'feDiffuseLighting', 44 | 'feDisplacementMap', 45 | 'feDistantLight', 46 | 'feDropShadow', 47 | 'feFlood', 48 | 'feFuncA', 49 | 'feFuncB', 50 | 'feFuncG', 51 | 'feFuncR', 52 | 'feGaussianBlur', 53 | 'feImage', 54 | 'feMerge', 55 | 'feMergeNode', 56 | 'feMorphology', 57 | 'feOffset', 58 | 'fePointLight', 59 | 'feSpecularLighting', 60 | 'feSpotLight', 61 | 'feTile', 62 | 'feTurbulence', 63 | 'filter', 64 | 'foreignObject', 65 | 'g', 66 | 'line', 67 | 'linearGradient', 68 | 'marker', 69 | 'mask', 70 | 'metadata', 71 | 'mpath', 72 | 'path', 73 | 'pattern', 74 | 'polygon', 75 | 'polyline', 76 | 'radialGradient', 77 | 'rect', 78 | 'set', 79 | 'stop', 80 | 'svg', 81 | 'switch', 82 | 'symbol', 83 | 'text', 84 | 'textPath', 85 | 'title', 86 | 'tspan', 87 | 'unknown', 88 | 'use', 89 | 'view' 90 | ] 91 | } 92 | 93 | export const tagNamespaceMap = reverseMap(namespaceToTagsMap) 94 | export const propAliases = prefix('attr:', attributes) 95 | 96 | export const directives = { 97 | style(key) { 98 | return (node, val) => { 99 | if (val === undefined || val === null) return 100 | 101 | const styleObj = node.style 102 | 103 | const handler = (newVal) => nextTick(() => { 104 | if (newVal === undefined || val === null || val === false) styleObj[key] = 'unset' 105 | else styleObj[key] = newVal 106 | }) 107 | 108 | bind(handler, val) 109 | } 110 | }, 111 | class(key) { 112 | return (node, val) => { 113 | if (val === undefined || val === null) return 114 | 115 | const classList = node.classList 116 | 117 | const handler = (newVal) => nextTick(() => { 118 | if (newVal) classList.add(key) 119 | else classList.remove(key) 120 | }) 121 | 122 | bind(handler, val) 123 | } 124 | } 125 | } 126 | 127 | const onDirective = (prefix, key) => { 128 | const handler = directives[prefix] 129 | if (handler) return handler(key) 130 | } 131 | 132 | export const defaults = { 133 | doc: document, 134 | namespaces, 135 | tagNamespaceMap, 136 | tagAliases, 137 | propAliases, 138 | onDirective 139 | } 140 | -------------------------------------------------------------------------------- /src/renderer.js: -------------------------------------------------------------------------------- 1 | import { render, createComponent } from './component.js' 2 | import { isSignal } from './signal.js' 3 | import { removeFromArr } from './utils.js' 4 | 5 | const Fragment = '<>' 6 | 7 | const createRenderer = (nodeOps, rendererID) => { 8 | const { 9 | isNode, 10 | createNode, 11 | createTextNode, 12 | createAnchor, 13 | createFragment: createFragmentRaw, 14 | removeNode: removeNodeRaw, 15 | appendNode: appendNodeRaw, 16 | insertBefore: insertBeforeRaw, 17 | setProps, 18 | } = nodeOps 19 | 20 | const fragmentMap = new WeakMap() 21 | const parentMap = new WeakMap() 22 | 23 | const isFragment = i => i && fragmentMap.has(i) 24 | 25 | const createFragment = (name) => { 26 | const fragment = createFragmentRaw() 27 | const anchorStart = createAnchor(`<${name}>`) 28 | const anchorEnd = createAnchor(``) 29 | appendNodeRaw(fragment, anchorStart, anchorEnd) 30 | parentMap.set(anchorStart, fragment) 31 | parentMap.set(anchorEnd, fragment) 32 | fragmentMap.set(fragment, [anchorStart, [], anchorEnd, {connected: false}]) 33 | return fragment 34 | } 35 | 36 | const flattenChildren = (children) => children.reduce((result, i) => { 37 | if (isFragment(i)) result.push(...expandFragment(i)) 38 | else result.push(i) 39 | return result 40 | }, []) 41 | 42 | const expandFragment = (node) => { 43 | const [anchorStart, children, anchorEnd, flags] = fragmentMap.get(node) 44 | if (flags.connected) { 45 | return [anchorStart, ...flattenChildren(children), anchorEnd] 46 | } 47 | 48 | flags.connected = true 49 | return [node] 50 | } 51 | 52 | const removeNode = (node) => { 53 | const parent = parentMap.get(node) 54 | 55 | if (!parent) return 56 | 57 | if (isFragment(parent)) { 58 | const [, children] = fragmentMap.get(parent) 59 | removeFromArr(children, node) 60 | } 61 | 62 | parentMap.delete(node) 63 | 64 | if (isFragment(node)) { 65 | const [, , , flags] = fragmentMap.get(node) 66 | if (flags.connected) { 67 | appendNodeRaw(node, ...expandFragment(node)) 68 | flags.connected = false 69 | } 70 | } else { 71 | removeNodeRaw(node) 72 | } 73 | } 74 | 75 | const appendNode = (parent, ...nodes) => { 76 | if (isFragment(parent)) { 77 | const [, , anchorEnd] = fragmentMap.get(parent) 78 | for (let node of nodes) { 79 | insertBefore(node, anchorEnd) 80 | } 81 | return 82 | } else { 83 | for (let node of nodes) { 84 | removeNode(node) 85 | parentMap.set(node, parent) 86 | } 87 | appendNodeRaw(parent, ...flattenChildren(nodes)) 88 | } 89 | } 90 | 91 | const insertBefore = (node, ref) => { 92 | removeNode(node) 93 | 94 | const parent = parentMap.get(ref) 95 | parentMap.set(node, parent) 96 | 97 | if (isFragment(parent)) { 98 | const [, children] = fragmentMap.get(parent) 99 | const idx = children.indexOf(ref) 100 | children.splice(idx, 0, node) 101 | } 102 | 103 | if (isFragment(ref)) { 104 | const [anchorStart] = fragmentMap.get(ref) 105 | ref = anchorStart 106 | } 107 | 108 | if (isFragment(node)) { 109 | for (let child of expandFragment(node)) insertBeforeRaw(child, ref) 110 | return 111 | } 112 | 113 | return insertBeforeRaw(node, ref) 114 | } 115 | 116 | const ensureElement = (el) => { 117 | if (el === null || el === undefined || isNode(el)) return el 118 | return createTextNode(el) 119 | } 120 | 121 | const normalizeChildren = (children) => { 122 | const normalizedChildren = [] 123 | 124 | if (children.length) { 125 | let mergedTextBuffer = '' 126 | const flushTextBuffer = () => { 127 | if (mergedTextBuffer) { 128 | normalizedChildren.push(createTextNode(mergedTextBuffer)) 129 | mergedTextBuffer = '' 130 | } 131 | } 132 | for (let child of children) { 133 | if (child !== null && child !== undefined) { 134 | if (isNode(child)) { 135 | flushTextBuffer() 136 | normalizedChildren.push(child) 137 | } else if (isSignal(child)) { 138 | flushTextBuffer() 139 | normalizedChildren.push(createTextNode(child)) 140 | } else { 141 | mergedTextBuffer += child 142 | } 143 | } 144 | } 145 | flushTextBuffer() 146 | } 147 | 148 | return normalizedChildren 149 | } 150 | 151 | const createElement = (tag, props, ...children) => { 152 | if (typeof tag === 'string') { 153 | const normalizedChildren = normalizeChildren(children) 154 | const node = tag === Fragment ? createFragment('') : createNode(tag) 155 | 156 | if (props) { 157 | const { $ref, ..._props } = props 158 | setProps(node, _props) 159 | if ($ref) $ref.value = node 160 | } 161 | 162 | if (normalizedChildren.length) appendNode(node, ...normalizedChildren) 163 | 164 | return node 165 | } 166 | 167 | const instance = createComponent(tag, props, ...children) 168 | 169 | return ensureElement(render(instance, renderer)) 170 | } 171 | 172 | const renderComponent = (target, ...args) => { 173 | const instance = createComponent(...args) 174 | const node = render(instance, renderer) 175 | if (target && node) appendNode(target, node) 176 | return instance 177 | } 178 | 179 | const renderer = { 180 | ...nodeOps, 181 | nodeOps, 182 | id: rendererID || Symbol('rEFui renderer'), 183 | normalizeChildren, 184 | isFragment, 185 | createFragment, 186 | createElement, 187 | removeNode, 188 | appendNode, 189 | insertBefore, 190 | Fragment, 191 | render: renderComponent, 192 | text: createTextNode, 193 | c: createElement, 194 | f: Fragment 195 | } 196 | 197 | return renderer 198 | } 199 | 200 | export { createRenderer, Fragment } 201 | -------------------------------------------------------------------------------- /src/renderers/dom.js: -------------------------------------------------------------------------------- 1 | import { isSignal, nextTick, peek, bind } from '../signal.js' 2 | import { createRenderer } from '../renderer.js' 3 | import { nop, cachedStrKeyNoFalsy, splitFirst } from '../utils.js' 4 | 5 | /* 6 | const NODE_TYPES = { 7 | ELEMENT_NODE: 1, 8 | ATTRIBUTE_NODE: 2, 9 | TEXT_NODE: 3, 10 | CDATA_SECTION_NODE: 4, 11 | ENTITY_REFERENCE_NODE: 5, 12 | PROCESSING_INSTRUCTION_NODE: 7, 13 | COMMENT_NODE: 8, 14 | DOCUMENT_NODE: 9, 15 | DOCUMENT_FRAGMENT_NODE: 11 16 | } 17 | */ 18 | 19 | /* 20 | Apply order: 21 | 1. Get namespace 22 | 2. Get alias 23 | 3. Create with namespace 24 | */ 25 | 26 | const defaultRendererID = 'DOM' 27 | 28 | const createDOMRenderer = ({ 29 | rendererID = defaultRendererID, 30 | doc = document, 31 | namespaces = {}, 32 | tagNamespaceMap = {}, 33 | tagAliases = {}, 34 | propAliases = {}, 35 | onDirective 36 | } = {}) => { 37 | let eventPassiveSupported = false 38 | let eventOnceSupported = false 39 | 40 | try { 41 | const options = { 42 | passive: { 43 | get: () => { 44 | eventPassiveSupported = true 45 | return eventPassiveSupported 46 | } 47 | }, 48 | once: { 49 | get: () => { 50 | eventOnceSupported = true 51 | return eventOnceSupported 52 | } 53 | } 54 | } 55 | const testEvent = '__refui_event_option_test__' 56 | doc.addEventListener(testEvent, nop, options) 57 | doc.removeEventListener(testEvent, nop, options) 58 | } catch (e) { 59 | // do nothing 60 | } 61 | 62 | // eslint-disable-next-line max-params 63 | const eventCallbackFallback = (node, event, handler, options) => { 64 | if (options.once && !eventOnceSupported) { 65 | const _handler = handler 66 | handler = (...args) => { 67 | _handler(...args) 68 | node.removeEventListener(event, handler, options) 69 | } 70 | } 71 | if (options.passive && !eventPassiveSupported) { 72 | const _handler = handler 73 | handler = (...args) => { 74 | nextTick(() => _handler(...args)) 75 | } 76 | } 77 | 78 | return handler 79 | } 80 | 81 | const isNode = node => !!(node && node.cloneNode) 82 | 83 | const getNodeCreator = cachedStrKeyNoFalsy((tagNameRaw) => { 84 | let [nsuri, tagName] = tagNameRaw.split(':') 85 | if (!tagName) { 86 | tagName = nsuri 87 | nsuri = tagNamespaceMap[tagName] 88 | } 89 | tagName = tagAliases[tagName] || tagName 90 | if (nsuri) { 91 | nsuri = namespaces[nsuri] || nsuri 92 | return () => doc.createElementNS(nsuri, tagName) 93 | } 94 | return () => doc.createElement(tagName) 95 | }) 96 | 97 | const createNode = tagName => getNodeCreator(tagName)() 98 | const createAnchor = (anchorName) => { 99 | if (process.env.NODE_ENV === 'development') return doc.createComment(anchorName || '') 100 | return doc.createTextNode('') 101 | } 102 | const createTextNode = (text) => { 103 | if (isSignal(text)) { 104 | const node = doc.createTextNode('') 105 | text.connect(() => { 106 | const newData = peek(text) 107 | if (newData === undefined) node.data = '' 108 | else node.data = newData 109 | }) 110 | return node 111 | } 112 | 113 | return doc.createTextNode(text) 114 | } 115 | const createFragment = () => doc.createDocumentFragment() 116 | 117 | const removeNode = (node) => { 118 | if (!node.parentNode) return 119 | node.parentNode.removeChild(node) 120 | } 121 | const appendNode = (parent, ...nodes) => { 122 | for (let node of nodes) { 123 | parent.insertBefore(node, null) 124 | } 125 | } 126 | const insertBefore = (node, ref) => { 127 | ref.parentNode.insertBefore(node, ref) 128 | } 129 | 130 | const getListenerAdder = cachedStrKeyNoFalsy((event) => { 131 | const [prefix, eventName] = event.split(':') 132 | if (prefix === 'on') { 133 | return (node, cb) => { 134 | if (!cb) return 135 | if (isSignal(cb)) { 136 | let currentHandler = null 137 | cb.connect(() => { 138 | const newHandler = peek(cb) 139 | if (currentHandler) node.removeEventListener(eventName, currentHandler) 140 | if (newHandler) node.addEventListener(eventName, newHandler) 141 | currentHandler = newHandler 142 | }) 143 | } else node.addEventListener(eventName, cb) 144 | } 145 | } else { 146 | const optionsArr = prefix.split('-') 147 | optionsArr.shift() 148 | const options = {} 149 | for (let option of optionsArr) if (option) options[option] = true 150 | return (node, cb) => { 151 | if (!cb) return 152 | if (isSignal(cb)) { 153 | let currentHandler = null 154 | cb.connect(() => { 155 | let newHandler = peek(cb) 156 | if (currentHandler) node.removeEventListener(eventName, currentHandler, options) 157 | if (newHandler) { 158 | newHandler = eventCallbackFallback(node, eventName, newHandler, options) 159 | node.addEventListener(eventName, newHandler, options) 160 | } 161 | currentHandler = newHandler 162 | }) 163 | } else node.addEventListener(eventName, eventCallbackFallback(node, eventName, cb, options), options) 164 | } 165 | } 166 | }) 167 | const addListener = (node, event, cb) => { 168 | getListenerAdder(event)(node, cb) 169 | } 170 | 171 | const setAttr = (node, attr, val) => { 172 | if (val === undefined || val === null || val === false) return 173 | 174 | const handler = (newVal) => { 175 | if (newVal === undefined || newVal === null || newVal === false) node.removeAttribute(attr) 176 | else if (newVal === true) node.setAttribute(attr, '') 177 | else node.setAttribute(attr, newVal) 178 | } 179 | 180 | bind(handler, val) 181 | } 182 | // eslint-disable-next-line max-params 183 | const setAttrNS = (node, attr, val, ns) => { 184 | if (val === undefined || val === null || val === false) return 185 | 186 | const handler = (newVal) => { 187 | if (newVal === undefined || newVal === null || newVal === false) node.removeAttributeNS(ns, attr) 188 | else if (newVal === true) node.setAttributeNS(ns, attr, '') 189 | else node.setAttributeNS(ns, attr, newVal) 190 | } 191 | 192 | bind(handler, val) 193 | } 194 | 195 | const getPropSetter = cachedStrKeyNoFalsy((prop) => { 196 | prop = propAliases[prop] || prop 197 | const [prefix, key] = splitFirst(prop, ':') 198 | if (key) { 199 | switch (prefix) { 200 | default: { 201 | if (prefix === 'on' || prefix.startsWith('on-')) return (node, val) => addListener(node, prop, val) 202 | if (onDirective) { 203 | const setter = onDirective(prefix, key, prop) 204 | if (setter) return setter 205 | } 206 | const nsuri = namespaces[prefix] || prefix 207 | return (node, val) => setAttrNS(node, key, val, nsuri) 208 | } 209 | case 'attr': { 210 | return (node, val) => setAttr(node, key, val) 211 | } 212 | case 'prop': { 213 | prop = key 214 | } 215 | } 216 | } else if (prop.indexOf('-') > -1) { 217 | return (node, val) => setAttr(node, prop, val) 218 | } 219 | 220 | return (node, val) => { 221 | if (val === undefined || val === null) return 222 | if (isSignal(val)) val.connect(() => (node[prop] = peek(val))) 223 | else node[prop] = val 224 | } 225 | }) 226 | 227 | const setProps = (node, props) => { 228 | for (let prop in props) getPropSetter(prop)(node, props[prop]) 229 | } 230 | 231 | const nodeOps = { 232 | isNode, 233 | createNode, 234 | createAnchor, 235 | createTextNode, 236 | createFragment, 237 | setProps, 238 | insertBefore, 239 | appendNode, 240 | removeNode 241 | } 242 | 243 | return createRenderer(nodeOps, rendererID) 244 | } 245 | 246 | export { createDOMRenderer, defaultRendererID } 247 | -------------------------------------------------------------------------------- /src/renderers/html.js: -------------------------------------------------------------------------------- 1 | import { isSignal, nextTick, peek, bind } from '../signal.js' 2 | import { createRenderer } from '../renderer.js' 3 | import { nop, cachedStrKeyNoFalsy, removeFromArr } from '../utils.js' 4 | 5 | const FLAG_NODE = Symbol(process.env.NODE_ENV === 'production' ? '' : 'F_Node') 6 | const FLAG_FRAG = Symbol(process.env.NODE_ENV === 'production' ? '' : 'F_Fragment') 7 | const FLAG_SELF_CLOSING = Symbol(process.env.NODE_ENV === 'production' ? '' : 'F_SelfClosing') 8 | const KEY_TAG_NAME = Symbol(process.env.NODE_ENV === 'production' ? '' : 'K_TagName') 9 | 10 | const escapeMap = { 11 | '<': '<', 12 | '>': '>', 13 | '"': '"', 14 | "'": ''', 15 | '&': '&' 16 | } 17 | 18 | const escapeHtml = (unsafe) => { 19 | return `${unsafe}` 20 | .replace(/[<>"'&]/g, (match) => escapeMap[match]) 21 | } 22 | 23 | 24 | const makeNode = (node) => { 25 | node[FLAG_NODE] = true 26 | node.parent = null 27 | return node 28 | } 29 | 30 | const defaultRendererID = 'HTML' 31 | 32 | const serialize = (node) => node.flat(Infinity).join('') 33 | 34 | const createHTMLRenderer = ({ 35 | rendererID = defaultRendererID, 36 | selfClosingTags = { 37 | hr: true, 38 | br: true, 39 | input: true, 40 | img: true, 41 | }, 42 | } = {}) => { 43 | const isNode = (node) => !!(node && node[FLAG_NODE]) 44 | 45 | const createNode = (tagName) => { 46 | const node = makeNode([`<${tagName}`, []]) 47 | if (selfClosingTags[tagName]) { 48 | node.push('/>') 49 | node[FLAG_SELF_CLOSING] = true 50 | node[KEY_TAG_NAME] = tagName 51 | } else { 52 | node.push('>', [], ``) 53 | } 54 | node.nodeName = tagName 55 | return node 56 | } 57 | const createAnchor = (anchorName) => makeNode(['']) 58 | const createTextNode = (text) => { 59 | if (isSignal(text)) { 60 | const node = makeNode(['']) 61 | text.connect(() => { 62 | const newData = peek(text) 63 | if (newData === undefined || newData === null) node[0] = '' 64 | else node[0] = escapeHtml(newData) 65 | }) 66 | return node 67 | } 68 | 69 | return makeNode([escapeHtml(text)]) 70 | } 71 | const createFragment = () => { 72 | const frag = makeNode([]) 73 | frag[FLAG_FRAG] = true 74 | return frag 75 | } 76 | 77 | const revokeSelfClosing = (parent) => { 78 | if (parent[FLAG_SELF_CLOSING]) { 79 | parent.pop() 80 | parent.push('>', [], ``) 81 | delete parent[FLAG_SELF_CLOSING] 82 | delete parent[KEY_TAG_NAME] 83 | } 84 | } 85 | 86 | const removeNode = (node) => { 87 | if (!node.parent) return 88 | removeFromArr(node.parent, node) 89 | node.parent = null 90 | } 91 | const appendNode = (parent, ...nodes) => { 92 | let _parent = parent 93 | if (!parent[FLAG_FRAG]) { 94 | revokeSelfClosing(parent) 95 | _parent = parent[3] 96 | } 97 | for (let node of nodes) { 98 | if (node[FLAG_FRAG]) { 99 | for (let _node of node) { 100 | _node.parent = _parent 101 | } 102 | _parent.push(...node) 103 | node.length = 0 104 | } else { 105 | _parent.push(node) 106 | node.parent = _parent 107 | } 108 | } 109 | } 110 | const insertBefore = (node, ref) => { 111 | const parent = ref.parent 112 | if (!parent) { 113 | throw new ReferenceError('InsertBefore: Ref does not have a parent!') 114 | } 115 | 116 | const index = parent.indexOf(ref) 117 | if (index > -1) { 118 | if (node[FLAG_FRAG]) { 119 | for (let _node of node) { 120 | _node.parent = parent 121 | } 122 | parent.splice(index, 0, ...node) 123 | node.length = 0 124 | } else { 125 | parent.splice(index, 0, node) 126 | node.parent = parent 127 | } 128 | } else { 129 | throw new ReferenceError('InsertBefore: Ref not in parent!') 130 | } 131 | } 132 | 133 | const getPropSetter = cachedStrKeyNoFalsy((key) => { 134 | const [prefix, _key] = key.split(':') 135 | if (_key) { 136 | switch (prefix) { 137 | case 'on': { 138 | return nop 139 | } 140 | case 'attr': { 141 | key = _key 142 | break 143 | } 144 | default: { 145 | // do nothing 146 | } 147 | } 148 | } 149 | 150 | const propHeader = ` ${key}="` 151 | 152 | return (propsNode, val) => { 153 | if (isSignal(val)) { 154 | const propNode = [propHeader, '', '"'] 155 | val.connect(() => { 156 | const newData = peek(val) 157 | if (newData === undefined || newData === null) { 158 | removeFromArr(propsNode, propNode) 159 | propNode[1] = '' 160 | } else { 161 | if (propsNode.indexOf(propNode) < 0) { 162 | propsNode.push(propNode) 163 | } 164 | propNode[1] = escapeHtml(newData) 165 | } 166 | }) 167 | } else if (val !== undefined && val !== null) { 168 | propsNode.push(`${propHeader}${escapeHtml(val)}"`) 169 | } 170 | } 171 | }) 172 | 173 | const setProps = (node, props) => { 174 | if (node[FLAG_FRAG]) return 175 | const propsNode = node[1] 176 | for (let key in props) { 177 | getPropSetter(key)(propsNode, props[key]) 178 | } 179 | } 180 | 181 | const nodeOps = { 182 | isNode, 183 | createNode, 184 | createAnchor, 185 | createTextNode, 186 | createFragment, 187 | setProps, 188 | insertBefore, 189 | appendNode, 190 | removeNode, 191 | serialize, 192 | } 193 | 194 | return createRenderer(nodeOps, rendererID) 195 | } 196 | 197 | export { createHTMLRenderer, defaultRendererID } 198 | -------------------------------------------------------------------------------- /src/renderers/jsx-dev-runtime.js: -------------------------------------------------------------------------------- 1 | import { nop } from '../utils.js' 2 | 3 | let jsxDEV = nop 4 | let Fragment = '<>' 5 | 6 | const wrap = (R) => { 7 | jsxDEV = (tag, {children = [], ...props}, key, ...args) => { 8 | try { 9 | if (key) { 10 | props.key = key 11 | } 12 | return R.c(tag, props, ...children) 13 | } catch (e) { 14 | throw new Error(`Error happened while rendering component ${args.join(' ')}`, { cause: e }) 15 | } 16 | } 17 | Fragment = R.f 18 | 19 | return { 20 | jsxDEV, 21 | Fragment 22 | } 23 | } 24 | 25 | const _default = { 26 | wrap, 27 | get default() { 28 | return _default; 29 | }, 30 | get jsxDEV() { 31 | return jsxDEV; 32 | }, 33 | get Fragment() { 34 | return Fragment 35 | } 36 | } 37 | 38 | export default _default 39 | export { 40 | wrap, 41 | jsxDEV, 42 | Fragment 43 | } 44 | -------------------------------------------------------------------------------- /src/renderers/jsx-runtime.js: -------------------------------------------------------------------------------- 1 | import { nop } from '../utils.js' 2 | 3 | let jsx = nop 4 | let jsxs = nop 5 | let Fragment = '<>' 6 | 7 | const wrap = (R) => { 8 | jsx = (tag, {children, ...props}, key) => { 9 | if (key) { 10 | props.key = key 11 | } 12 | return R.c(tag, props, children) 13 | } 14 | jsxs = (tag, {children = [], ...props}, key) => { 15 | if (key) { 16 | props.key = key 17 | } 18 | return R.c(tag, props, ...children) 19 | } 20 | Fragment = R.f 21 | 22 | return { 23 | jsx, 24 | jsxs, 25 | Fragment 26 | } 27 | } 28 | 29 | const _default = { 30 | wrap, 31 | get default() { 32 | return _default; 33 | }, 34 | get jsx() { 35 | return jsx; 36 | }, 37 | get jsxs() { 38 | return jsxs; 39 | }, 40 | get Fragment() { 41 | return Fragment 42 | } 43 | } 44 | 45 | export default _default 46 | export { 47 | wrap, 48 | jsx, 49 | jsxs, 50 | Fragment 51 | } 52 | -------------------------------------------------------------------------------- /src/signal.js: -------------------------------------------------------------------------------- 1 | import { removeFromArr } from './utils.js' 2 | 3 | let sigID = 0 4 | let ticking = false 5 | let currentEffect = null 6 | let currentDisposers = null 7 | let currentResolve = null 8 | let currentTick = null 9 | 10 | let signalQueue = new Set() 11 | let effectQueue = new Set() 12 | let runQueue = new Set() 13 | 14 | // Scheduler part 15 | 16 | const scheduleSignal = signalEffects => signalQueue.add(signalEffects) 17 | const scheduleEffect = effects => effectQueue.add(effects) 18 | 19 | const flushRunQueue = () => { 20 | for (let i of runQueue) i() 21 | runQueue.clear() 22 | } 23 | 24 | const flushQueue = (queue, sorted) => { 25 | while (queue.size) { 26 | const queueArr = Array.from(queue) 27 | queue.clear() 28 | 29 | if (sorted && queueArr.length > 1) { 30 | queueArr.sort((a, b) => a._id - b._id) 31 | const tempArr = [...(new Set([].concat(...queueArr).reverse()))].reverse() 32 | runQueue = new Set(tempArr) 33 | } else if (queueArr.length > 10000) { 34 | let flattenedArr = [] 35 | for (let i = 0; i < queueArr.length; i += 10000) { 36 | flattenedArr = flattenedArr.concat(...queueArr.slice(i, i + 10000)) 37 | } 38 | runQueue = new Set(flattenedArr) 39 | } else { 40 | runQueue = new Set([].concat(...queueArr)) 41 | } 42 | flushRunQueue() 43 | } 44 | } 45 | 46 | const tick = () => { 47 | if (!ticking) { 48 | ticking = true 49 | currentResolve() 50 | } 51 | return currentTick 52 | } 53 | 54 | const nextTick = cb => tick().then(cb) 55 | 56 | const flushQueues = () => { 57 | if (signalQueue.size || effectQueue.size) { 58 | flushQueue(signalQueue, true) 59 | signalQueue = new Set(signalQueue) 60 | flushQueue(effectQueue) 61 | effectQueue = new Set(effectQueue) 62 | return Promise.resolve().then(flushQueues) 63 | } 64 | } 65 | 66 | const resetTick = () => { 67 | ticking = false 68 | currentTick = new Promise((resolve) => { 69 | currentResolve = resolve 70 | }).then(flushQueues) 71 | currentTick.finally(resetTick) 72 | } 73 | 74 | // Signal part 75 | 76 | const pure = (cb) => { 77 | cb._pure = true 78 | return cb 79 | } 80 | 81 | const isPure = cb => !!cb._pure 82 | 83 | const createDisposer = (disposers, prevDisposers, dispose) => { 84 | let _dispose = () => { 85 | for (let i of disposers) i(true) 86 | disposers.length = 0 87 | } 88 | if (dispose) { 89 | const __dispose = _dispose 90 | _dispose = (batch) => { 91 | dispose(batch) 92 | __dispose(batch) 93 | } 94 | } 95 | if (prevDisposers) { 96 | const __dispose = _dispose 97 | _dispose = (batch) => { 98 | if (!batch) removeFromArr(prevDisposers, _dispose) 99 | __dispose(batch) 100 | } 101 | prevDisposers.push(_dispose) 102 | } 103 | 104 | return _dispose 105 | } 106 | 107 | const collectDisposers = (disposers, fn, dispose) => { 108 | const prevDisposers = currentDisposers 109 | const _dispose = createDisposer(disposers, prevDisposers, dispose) 110 | currentDisposers = disposers 111 | fn() 112 | currentDisposers = prevDisposers 113 | return _dispose 114 | } 115 | 116 | const _onDispose = (cb) => { 117 | const disposers = currentDisposers 118 | const dispose = (batch) => { 119 | if (!batch) removeFromArr(disposers, dispose) 120 | cb(batch) 121 | } 122 | disposers.push(dispose) 123 | return dispose 124 | } 125 | 126 | const onDispose = (cb) => { 127 | if (currentDisposers) { 128 | return _onDispose(cb) 129 | } 130 | } 131 | 132 | const useEffect = (effect) => { 133 | onDispose(effect()) 134 | } 135 | 136 | const untrack = (fn) => { 137 | const prevDisposers = currentDisposers 138 | const prevEffect = currentEffect 139 | currentDisposers = null 140 | currentEffect = null 141 | const ret = fn() 142 | currentDisposers = prevDisposers 143 | currentEffect = prevEffect 144 | return ret 145 | } 146 | 147 | const Signal = class { 148 | constructor(value, compute) { 149 | if (process.env.NODE_ENV === 'development' && new.target !== Signal) throw new Error('Signal must not be extended!') 150 | 151 | // eslint-disable-next-line no-plusplus 152 | const id = sigID++ 153 | const userEffects = [] 154 | const signalEffects = [] 155 | const disposeCtx = currentDisposers 156 | 157 | userEffects._id = id 158 | signalEffects._id = id 159 | 160 | const internal = { 161 | id, 162 | value, 163 | compute, 164 | disposeCtx, 165 | userEffects, 166 | signalEffects 167 | } 168 | 169 | Object.defineProperty(this, '_', { 170 | value: internal, 171 | writable: false, 172 | enumerable: false, 173 | configurable: false 174 | }) 175 | 176 | if (compute) { 177 | watch(pure(this.set.bind(this, value))) 178 | } else if (isSignal(value)) { 179 | value.connect(pure(this.set.bind(this, value))) 180 | } 181 | } 182 | 183 | get value() { 184 | return this.get() 185 | } 186 | 187 | set value(val) { 188 | this.set(val) 189 | } 190 | 191 | get connected() { 192 | const { userEffects, signalEffects } = this._ 193 | return !!(userEffects.length || signalEffects.length) 194 | } 195 | 196 | then(cb) { 197 | return Promise.resolve(this.get()).then(cb) 198 | } 199 | 200 | get() { 201 | this.connect(currentEffect) 202 | return this._.value 203 | } 204 | 205 | set(val) { 206 | const { compute, value } = this._ 207 | val = compute ? peek(compute(read(val))) : read(val) 208 | if (value !== val) { 209 | this._.value = val 210 | this.trigger() 211 | } 212 | } 213 | 214 | peek() { 215 | return this._.value 216 | } 217 | 218 | poke(val) { 219 | this._.value = val 220 | } 221 | 222 | trigger() { 223 | const { userEffects, signalEffects } = this._ 224 | scheduleSignal(signalEffects) 225 | scheduleEffect(userEffects) 226 | tick() 227 | } 228 | 229 | connect(effect) { 230 | if (!effect) return 231 | const { userEffects, signalEffects, disposeCtx } = this._ 232 | const effects = isPure(effect) ? signalEffects : userEffects 233 | if (!effects.includes(effect)) { 234 | effects.push(effect) 235 | if (currentDisposers && currentDisposers !== disposeCtx) { 236 | _onDispose(() => { 237 | removeFromArr(effects, effect) 238 | if (runQueue.size) runQueue.delete(effect) 239 | }) 240 | } 241 | } 242 | if (currentEffect !== effect) effect() 243 | } 244 | 245 | and(val) { 246 | return signal(this, i => read(val) && i) 247 | } 248 | 249 | or(val) { 250 | return signal(this, i => read(val) || i) 251 | } 252 | 253 | eq(val) { 254 | return signal(this, i => read(val) === i) 255 | } 256 | 257 | neq(val) { 258 | return signal(this, i => read(val) !== i) 259 | } 260 | 261 | gt(val) { 262 | return signal(this, i => i > read(val)) 263 | } 264 | 265 | lt(val) { 266 | return signal(this, i => i < read(val)) 267 | } 268 | 269 | toJSON() { 270 | return this.get() 271 | } 272 | 273 | *[Symbol.iterator]() { 274 | yield* this.get() 275 | } 276 | 277 | [Symbol.toPrimitive](hint) { 278 | const val = this.get() 279 | switch (hint) { 280 | case 'string': 281 | return String(val) 282 | case 'number': 283 | return Number(val) 284 | default: 285 | if (Object(val) !== val) return val 286 | return !!val 287 | } 288 | } 289 | } 290 | 291 | const isSignal = val => val && val.constructor === Signal 292 | 293 | const watch = (effect) => { 294 | const prevEffect = currentEffect 295 | currentEffect = effect 296 | const _dispose = collectDisposers([], effect) 297 | currentEffect = prevEffect 298 | 299 | return _dispose 300 | } 301 | 302 | const peek = (val) => { 303 | while (isSignal(val)) { 304 | val = val.peek() 305 | } 306 | return val 307 | } 308 | 309 | const poke = (val, newVal) => { 310 | if (isSignal(val)) return val.poke(newVal) 311 | return newVal 312 | } 313 | 314 | const read = (val) => { 315 | if (isSignal(val)) val = peek(val.get()) 316 | return val 317 | } 318 | 319 | const readAll = (vals, handler) => handler(...vals.map(read)) 320 | 321 | const _write = (val, newVal) => { 322 | if (typeof newVal === 'function') newVal = newVal(peek(val)) 323 | val.value = newVal 324 | return peek(val) 325 | } 326 | 327 | const write = (val, newVal) => { 328 | if (isSignal(val)) return _write(val, newVal) 329 | if (typeof newVal === 'function') return newVal(val) 330 | return newVal 331 | } 332 | 333 | const listen = (vals, cb) => { 334 | for (let val of vals) { 335 | if (isSignal(val)) { 336 | val.connect(cb) 337 | } 338 | } 339 | } 340 | 341 | const signal = (value, compute) => new Signal(value, compute) 342 | 343 | const computed = fn => signal(null, fn) 344 | const merge = (vals, handler) => computed(readAll.bind(null, vals, handler)) 345 | const tpl = (strs, ...exprs) => { 346 | const raw = { raw: strs } 347 | return signal(null, () => String.raw(raw, ...exprs)) 348 | } 349 | 350 | const connect = (sigs, effect) => { 351 | const prevEffect = currentEffect 352 | currentEffect = effect 353 | for (let sig of sigs) { 354 | sig.connect(effect) 355 | } 356 | effect() 357 | currentEffect = prevEffect 358 | } 359 | 360 | const bind = (handler, val) => { 361 | if (isSignal(val)) val.connect(() => handler(peek(val))) 362 | else if (typeof val === 'function') watch(() => handler(val())) 363 | else handler(val) 364 | } 365 | 366 | const derive = (sig, key, compute) => { 367 | if (isSignal(sig)) { 368 | const derivedSig = signal(null, compute) 369 | let disposer = null 370 | 371 | const _dispose = () => { 372 | if (disposer) { 373 | disposer() 374 | disposer = null 375 | } 376 | } 377 | 378 | sig.connect(pure(() => { 379 | _dispose() 380 | const newVal = peek(sig) 381 | if (!newVal) return 382 | 383 | untrack(() => { 384 | disposer = watch(() => { 385 | derivedSig.value = read(newVal[key]) 386 | }) 387 | }) 388 | })) 389 | 390 | onDispose(_dispose) 391 | 392 | return derivedSig 393 | } else { 394 | return signal(sig[key], compute) 395 | } 396 | } 397 | 398 | const extract = (sig, ...extractions) => { 399 | if (!extractions.length) { 400 | extractions = Object.keys(peek(sig)) 401 | } 402 | 403 | return extractions.reduce((mapped, i) => { 404 | mapped[i] = signal(sig, val => val && peek(val[i])) 405 | return mapped 406 | }, {}) 407 | } 408 | const derivedExtract = (sig, ...extractions) => { 409 | if (!extractions.length) { 410 | extractions = Object.keys(peek(sig)) 411 | } 412 | 413 | return extractions.reduce((mapped, i) => { 414 | mapped[i] = derive(sig, i) 415 | return mapped 416 | }, {}) 417 | } 418 | 419 | const makeReactive = (obj) => Object.defineProperties({}, Object.entries(obj).reduce((descriptors, [key, value]) => { 420 | if (isSignal(value)) { 421 | descriptors[key] = { 422 | get: value.get.bind(value), 423 | set: value.set.bind(value), 424 | enumerable: true, 425 | configurable: false 426 | } 427 | } else { 428 | descriptors[key] = { 429 | value, 430 | enumerable: true 431 | } 432 | } 433 | 434 | return descriptors 435 | }, {})) 436 | 437 | const onCondition = (sig, compute) => { 438 | let currentVal = null 439 | let conditionMap = new Map() 440 | let conditionValMap = new Map() 441 | sig.connect( 442 | pure(() => { 443 | const newVal = peek(sig) 444 | if (currentVal !== newVal) { 445 | const prevMatchSet = conditionMap.get(currentVal) 446 | const newMatchSet = conditionMap.get(newVal) 447 | 448 | currentVal = newVal 449 | 450 | if (prevMatchSet) { 451 | for (let i of prevMatchSet) i.value = false 452 | } 453 | if (newMatchSet) { 454 | for (let i of newMatchSet) i.value = true 455 | } 456 | } 457 | }) 458 | ) 459 | 460 | if (currentDisposers) { 461 | _onDispose(() => { 462 | conditionMap = new Map() 463 | conditionValMap = new Map() 464 | }) 465 | } 466 | 467 | const match = (condition) => { 468 | let currentCondition = peek(condition) 469 | let matchSet = conditionMap.get(currentCondition) 470 | if (isSignal(condition)) { 471 | let matchSig = conditionValMap.get(condition) 472 | if (!matchSig) { 473 | matchSig = signal(currentCondition === currentVal, compute) 474 | conditionValMap.set(condition, matchSig) 475 | 476 | condition.connect(() => { 477 | currentCondition = peek(condition) 478 | if (matchSet) removeFromArr(matchSet, matchSig) 479 | matchSet = conditionMap.get(currentCondition) 480 | if (!matchSet) { 481 | matchSet = [] 482 | conditionMap.set(currentCondition, matchSet) 483 | } 484 | matchSet.push(matchSig) 485 | matchSig.value = currentCondition === currentVal 486 | }) 487 | 488 | if (currentDisposers) { 489 | _onDispose(() => { 490 | conditionValMap.delete(condition) 491 | if (matchSet.length === 1) conditionMap.delete(currentCondition) 492 | else removeFromArr(matchSet, matchSig) 493 | }) 494 | } 495 | } 496 | return matchSig 497 | } else { 498 | if (!matchSet) { 499 | matchSet = [] 500 | conditionMap.set(currentCondition, matchSet) 501 | } 502 | let matchSig = conditionValMap.get(currentCondition) 503 | if (!matchSig) { 504 | matchSig = signal(currentCondition === currentVal, compute) 505 | conditionValMap.set(currentCondition, matchSig) 506 | matchSet.push(matchSig) 507 | 508 | if (currentDisposers) { 509 | _onDispose(() => { 510 | conditionValMap.delete(currentCondition) 511 | if (matchSet.length === 1) conditionMap.delete(currentCondition) 512 | else removeFromArr(matchSet, matchSig) 513 | }) 514 | } 515 | } 516 | return matchSig 517 | } 518 | } 519 | 520 | return match 521 | } 522 | 523 | resetTick() 524 | 525 | export { 526 | Signal, 527 | signal, 528 | isSignal, 529 | computed, 530 | connect, 531 | bind, 532 | derive, 533 | extract, 534 | derivedExtract, 535 | makeReactive, 536 | tpl, 537 | watch, 538 | peek, 539 | poke, 540 | read, 541 | readAll, 542 | merge, 543 | write, 544 | listen, 545 | scheduleEffect as schedule, 546 | tick, 547 | nextTick, 548 | collectDisposers, 549 | onCondition, 550 | onDispose, 551 | useEffect, 552 | untrack 553 | } 554 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-empty-function 2 | export const nop = () => {} 3 | 4 | export const cached = (handler) => { 5 | const store = new Map() 6 | return (arg) => { 7 | let val = store.get(arg) 8 | if (!val) { 9 | val = handler(arg) 10 | store.set(arg, val) 11 | } 12 | return val 13 | } 14 | } 15 | 16 | export const cachedStrKeyNoFalsy = (handler) => { 17 | const store = {__proto__: null} 18 | return (key) => (store[key] || (store[key] = handler(key))) 19 | } 20 | 21 | export const removeFromArr = (arr, val) => { 22 | const index = arr.indexOf(val) 23 | if (index > -1) { 24 | arr.splice(index, 1) 25 | } 26 | } 27 | 28 | export const isPrimitive = (val) => Object(val) !== val 29 | 30 | export const splitFirst = (val, splitter) => { 31 | const idx = val.indexOf(splitter) 32 | if (idx < 0) return [val] 33 | const front = val.slice(0, idx) 34 | const back = val.slice(idx + splitter.length, val.length) 35 | return [front, back] 36 | } 37 | --------------------------------------------------------------------------------