├── .dldc.json ├── .gitignore ├── .node-version ├── .prettierignore ├── .prettierrc.json ├── .release-it.json ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── design ├── PoiretOne-Regular.ttf ├── logo.sketch └── logo.svg ├── eslint.config.js ├── package.json ├── pnpm-lock.yaml ├── src ├── ChildrenUtils.ts ├── Global.ts ├── Hooks.ts ├── Store.ts ├── mod.ts ├── symbols.ts ├── types.ts └── utils.ts ├── tests ├── index.test.tsx └── utils.ts ├── tsconfig.json └── vitest.config.ts /.dldc.json: -------------------------------------------------------------------------------- 1 | { 2 | "skipLibCheck": true, 3 | "react": true 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | 11 | *.tsbuildinfo 12 | coverage 13 | node_modules/ 14 | jspm_packages/ 15 | .env 16 | .env.test 17 | 18 | dist 19 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | v20.12.2 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "useTabs": false 6 | } 7 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "before:init": ["pnpm run build", "pnpm test"] 4 | }, 5 | "npm": { 6 | "publish": true 7 | }, 8 | "git": { 9 | "changelog": "pnpm run --silent changelog" 10 | }, 11 | "github": { 12 | "release": true, 13 | "web": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "eslint.useFlatConfig": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Etienne Dldc 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | democrat logo 3 |

4 | 5 | # 📜 democrat 6 | 7 | > React, but for state management ! 8 | 9 | Democrat is a library that mimic the API of React (Components, hooks, Context...) but instead of producing DOM mutation it produces a state tree. 10 | You can then use this state tree as global state management system (like redux or mobx). 11 | 12 | ## Project Status 13 | 14 | While this project is probably not 100% stable it has a decent amount of tests and is used in a few projects without any issue. 15 | 16 | ## Install 17 | 18 | ```bash 19 | npm install democrat 20 | ``` 21 | 22 | ## Gist 23 | 24 | ```ts 25 | import { useState, useCallback, createElement } from 'democrat'; 26 | 27 | // Create a Democrat "component" 28 | const MainStore = () => { 29 | // all your familiar hooks are here 30 | const [count, setCount] = useState(0); 31 | 32 | const increment = useCallback(() => setCount(prev => prev + 1), []); 33 | 34 | // return your state at the end 35 | return { 36 | count, 37 | increment, 38 | }; 39 | }; 40 | 41 | // Render your component 42 | const store = Democrat.render(createElement(MainStore, {})); 43 | // subscribe to state update 44 | store.subscribe(render); 45 | render(); 46 | 47 | function render = () => { 48 | console.log(store.getState()); 49 | }; 50 | ``` 51 | 52 | ## How is this different from React ? 53 | 54 | There are a few diffrences with React 55 | 56 | ### 1. Return value 57 | 58 | With Democrat instead of JSX, you return data. More precisly, you return what you want to expose in your state. 59 | 60 | ### 2. `useChildren` 61 | 62 | In React to use other component you have to return an element of it in your render. In Democrat you can't do that since what you return is your state. Instead you can use the `useChildren` hook. 63 | The `useChildren` is very similar to when you return `` in React: 64 | 65 | - It will create a diff to define what to update/mount/unmount 66 | - If props don't change it will not re-render but re-use the previous result instead 67 | But the difference is that you get the result of that children an can use it in the parent component. 68 | 69 | ```ts 70 | const Child = () => { 71 | // .. 72 | return { some: 'data' }; 73 | }; 74 | 75 | const Parent = () => { 76 | //... 77 | const childData = Democrat.useChildren(Democrat.createElement(Child, {})); 78 | // childData = { some: 'data' } 79 | //... 80 | return { children: childData }; 81 | }; 82 | ``` 83 | 84 | ### 3. `createElement` signature 85 | 86 | The signature of Democrat's `createElement` is `createElement(Component, props, key)`. As you can see, unlike the React's one it does not accept `...children` as argument, instead you should pass children as a props. 87 | This difference mainly exist because of TypeScript since we can't correctly type `...children`. 88 | 89 | ## `useChildren` supported data 90 | 91 | `useChildren` supports the following data structure: 92 | 93 | - `Array` (`[]`) 94 | - `Object` (`{}`) 95 | - `Map` 96 | 97 | ```ts 98 | const Child = () => { 99 | return 42; 100 | }; 101 | 102 | const Parent = () => { 103 | //... 104 | const childData = Democrat.useChildren({ 105 | a: Democrat.createElement(Child, {}), 106 | b: Democrat.createElement(Child, {}), 107 | }); 108 | // childData = { a: 42, b: 42 } 109 | //... 110 | return {}; 111 | }; 112 | ``` 113 | 114 | ## Using hooks library 115 | 116 | Because Democrat's hooks works just like React's ones with a little trick you can use some of React hooks in Democrat. This let you use third party hooks made for React directly in Democrat. 117 | All you need to do is pass the instance of `React` to the `Democrat.render` options. 118 | 119 | ```js 120 | import React from 'react'; 121 | import { render } from 'democrat'; 122 | 123 | render(/*...*/, { ReactInstance: React }); 124 | ``` 125 | 126 | For now the following hooks are supported: 127 | 128 | - `useState` 129 | - `useReducer` 130 | - `useEffect` 131 | - `useMemo` 132 | - `useCallback` 133 | - `useLayoutEffect` 134 | - `useRef` 135 | 136 | **Note**: While `useContext` exists in Democrat we cannot use the React version of it because of how context works (we would need to also replace `createContext` but we have no way to detect when we should create a Democrat context vs when we should create a React context...). 137 | 138 | ## `createFactory` 139 | 140 | The `createFactory` function is a small helper. It returns the `Component` you pass in as well as two functions: 141 | 142 | - `createElement`: to create an element out of the component by passing the props. 143 | - `useChildren`: to quickly use the component as children. 144 | 145 | ```js 146 | const Child = createFactory(({ name }) => {}); 147 | 148 | const Parent = createFactory(() => { 149 | const child1 = useChildren(Child.createElement({ name: 'Paul' })); 150 | const child2 = Child.useChildren({ name: 'Paul' }); 151 | }); 152 | ``` 153 | 154 | ## Components 155 | 156 | ```ts 157 | import * as Democrat from 'democrat'; 158 | 159 | const Counter = () => { 160 | const [count, setCount] = Democrat.useState(1); 161 | 162 | const increment = Democrat.useCallback(() => setCount((prev) => prev + 1), []); 163 | 164 | const result = Democrat.useMemo( 165 | () => ({ 166 | count, 167 | increment, 168 | }), 169 | [count, increment], 170 | ); 171 | 172 | return result; 173 | }; 174 | 175 | const Store = () => { 176 | const counter = Democrat.useChildren(Democrat.createElement(Counter, {})); 177 | const countersObject = Democrat.useChildren({ 178 | counterA: Democrat.createElement(Counter, {}), 179 | counterB: Democrat.createElement(Counter, {}), 180 | }); 181 | const countersArray = Democrat.useChildren( 182 | // create as many counters as `count` 183 | Array(counter.count) 184 | .fill(null) 185 | .map(() => Democrat.createElement(Counter, {})), 186 | ); 187 | 188 | return Democrat.useMemo( 189 | () => ({ 190 | counter, 191 | countersObject, 192 | countersArray, 193 | }), 194 | [counter, countersObject, countersArray], 195 | ); 196 | }; 197 | ``` 198 | -------------------------------------------------------------------------------- /design/PoiretOne-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dldc-packages/democrat/34c448446b1006cb5bcbefc6f7e1759843366170/design/PoiretOne-Regular.ttf -------------------------------------------------------------------------------- /design/logo.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dldc-packages/democrat/34c448446b1006cb5bcbefc6f7e1759843366170/design/logo.sketch -------------------------------------------------------------------------------- /design/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | logo 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 38 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | import hooksPlugin from 'eslint-plugin-react-hooks'; 4 | 5 | export default tseslint.config( 6 | { ignores: ['dist', 'coverage'] }, 7 | eslint.configs.recommended, 8 | ...tseslint.configs.recommendedTypeChecked, 9 | { 10 | languageOptions: { 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020, 14 | project: true, 15 | tsconfigRootDir: import.meta.dirname, 16 | }, 17 | }, 18 | rules: { 19 | 'no-constant-condition': 'off', 20 | '@typescript-eslint/ban-types': 'off', 21 | '@typescript-eslint/consistent-type-imports': 'error', 22 | '@typescript-eslint/no-base-to-string': 'off', 23 | '@typescript-eslint/no-empty-function': 'off', 24 | '@typescript-eslint/no-explicit-any': 'off', 25 | '@typescript-eslint/no-inferrable-types': 'off', 26 | '@typescript-eslint/no-non-null-assertion': 'off', 27 | '@typescript-eslint/no-redundant-type-constituents': 'off', 28 | '@typescript-eslint/no-this-alias': 'off', 29 | '@typescript-eslint/no-unsafe-argument': 'off', 30 | '@typescript-eslint/no-unsafe-assignment': 'off', 31 | '@typescript-eslint/no-unsafe-call': 'off', 32 | '@typescript-eslint/no-unsafe-member-access': 'off', 33 | '@typescript-eslint/no-unsafe-return': 'off', 34 | '@typescript-eslint/no-unused-vars': 'off', 35 | '@typescript-eslint/unbound-method': 'off', 36 | }, 37 | }, 38 | { 39 | files: ['**/*.js'], 40 | extends: [tseslint.configs.disableTypeChecked], 41 | }, 42 | { 43 | plugins: { 'react-hooks': hooksPlugin }, 44 | rules: hooksPlugin.configs.recommended.rules, 45 | }, 46 | ); 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dldc/democrat", 3 | "version": "4.0.6", 4 | "description": "React, but for state management !", 5 | "keywords": [], 6 | "homepage": "https://github.com/dldc-packages/democrat#readme", 7 | "bugs": { 8 | "url": "https://github.com/dldc-packages/democrat/issues" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/dldc-packages/democrat.git" 13 | }, 14 | "license": "MIT", 15 | "author": "Etienne Dldc ", 16 | "sideEffects": false, 17 | "type": "module", 18 | "exports": { 19 | ".": { 20 | "types": "./dist/mod.d.ts", 21 | "import": "./dist/mod.js", 22 | "require": "./dist/mod.cjs" 23 | } 24 | }, 25 | "main": "./dist/mod.js", 26 | "types": "./dist/mod.d.ts", 27 | "files": [ 28 | "dist" 29 | ], 30 | "scripts": { 31 | "build": "rimraf dist && tsup --format cjs,esm src/mod.ts --dts", 32 | "build:watch": "tsup --watch --format cjs,esm src/mod.ts --dts", 33 | "changelog": "auto-changelog --stdout --hide-credit true --commit-limit false -u --template https://raw.githubusercontent.com/release-it/release-it/main/templates/changelog-compact.hbs", 34 | "lint": "prettier . --check && eslint . && tsc --noEmit", 35 | "lint:fix": "prettier . --write . && eslint . --fix", 36 | "release": "release-it --only-version", 37 | "test": "pnpm run lint && vitest run --coverage", 38 | "test:run": "vitest run", 39 | "test:watch": "vitest --watch", 40 | "test:watch:coverage": "vitest --watch --coverage", 41 | "typecheck": "tsc", 42 | "typecheck:watch": "tsc --watch" 43 | }, 44 | "dependencies": { 45 | "@dldc/pubsub": "^7.0.1" 46 | }, 47 | "devDependencies": { 48 | "@eslint/js": "^9.2.0", 49 | "@testing-library/jest-dom": "^6.4.5", 50 | "@testing-library/react": "^15.0.6", 51 | "@testing-library/user-event": "^14.5.2", 52 | "@types/node": "^20.12.8", 53 | "@types/react": "^18.3.1", 54 | "@types/react-dom": "^18.3.0", 55 | "@vitejs/plugin-react": "^4.2.1", 56 | "@vitest/coverage-v8": "^1.6.0", 57 | "auto-changelog": "^2.4.0", 58 | "eslint": "^8.57.0", 59 | "eslint-plugin-react-hooks": "^4.6.2", 60 | "jsdom": "^24.0.0", 61 | "prettier": "^3.2.5", 62 | "react": "^18.3.1", 63 | "react-dom": "^18.3.1", 64 | "release-it": "^17.2.1", 65 | "rimraf": "^5.0.5", 66 | "tsup": "^8.0.2", 67 | "typescript": "^5.4.5", 68 | "typescript-eslint": "^7.8.0", 69 | "vitest": "^1.6.0" 70 | }, 71 | "packageManager": "pnpm@9.0.6", 72 | "publishConfig": { 73 | "access": "public", 74 | "registry": "https://registry.npmjs.org" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/ChildrenUtils.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Context, 3 | EffectType, 4 | ElementComponent, 5 | HookSnapshot, 6 | TreeElement, 7 | TreeElementPath, 8 | TreeElementRaw, 9 | TreeElementSnapshot, 10 | TreeElementType, 11 | } from './types'; 12 | import { 13 | createTreeElement, 14 | getInstanceKey, 15 | isComponentElement, 16 | isElementInstance, 17 | isPlainObject, 18 | isProviderElement, 19 | isRootElement, 20 | isValidElement, 21 | mapMap, 22 | mapObject, 23 | markContextSubDirty, 24 | objectShallowEqual, 25 | registerContextSub, 26 | sameArrayStructure, 27 | sameMapStructure, 28 | sameObjectKeys, 29 | unregisterContextSub, 30 | } from './utils'; 31 | 32 | export const ChildrenUtils = { 33 | mount, 34 | update, 35 | effects, 36 | layoutEffects, 37 | unmount, 38 | access, 39 | snapshot, 40 | }; 41 | 42 | const CHILDREN_LIFECYCLES: { 43 | [K in TreeElementType]: { 44 | mount: ( 45 | element: TreeElementRaw[K], 46 | parent: TreeElement, 47 | path: TreeElementPath, 48 | snapshot: TreeElementSnapshot | undefined, 49 | ) => TreeElement; 50 | update: (instance: TreeElement, element: TreeElementRaw[K], path: TreeElementPath) => TreeElement; 51 | effect: (instance: TreeElement, effecType: EffectType) => void; 52 | cleanup: (instance: TreeElement, effecType: EffectType, force: boolean) => void; 53 | access: (instance: TreeElement, path: TreeElementPath) => TreeElement | null; 54 | snapshot: (instance: TreeElement) => TreeElementSnapshot; 55 | }; 56 | } = { 57 | ROOT: { 58 | mount: (element, parent, _path, snapshot) => { 59 | // when we mount root, parent is the root instance itself 60 | if (parent.type !== 'ROOT') { 61 | throw new Error(`Unexpected ROOT !`); 62 | } 63 | const children = parent.withGlobalRenderingInstance(parent, () => { 64 | return mount(element.children, parent, { type: 'ROOT' }, snapshot?.children); 65 | }); 66 | parent.mounted = true; 67 | parent.value = children.value; 68 | parent.children = children; 69 | return parent; 70 | }, 71 | update: (instance, element) => { 72 | const children = instance.withGlobalRenderingInstance(instance, () => { 73 | return update(instance.children, element.children, instance, { type: 'ROOT' }); 74 | }); 75 | instance.value = children.value; 76 | instance.children = children; 77 | instance.state = 'updated'; 78 | return instance; 79 | }, 80 | effect: (tree, type) => { 81 | effectInternal(tree.children, type); 82 | }, 83 | cleanup: (tree, type, force) => { 84 | cleanup(tree.children, type, force); 85 | }, 86 | access: (instance) => { 87 | return instance.children; 88 | }, 89 | snapshot: (instance) => { 90 | return { 91 | type: 'ROOT', 92 | children: snapshot(instance.children), 93 | }; 94 | }, 95 | }, 96 | NULL: { 97 | mount: (_element, parent, path) => { 98 | const item = createTreeElement('NULL', parent, path, { 99 | value: null, 100 | previous: null, 101 | }); 102 | return item; 103 | }, 104 | update: (tree) => tree, 105 | effect: () => { 106 | // 107 | }, 108 | cleanup: () => { 109 | return; 110 | }, 111 | access: () => { 112 | return null; 113 | }, 114 | snapshot: () => { 115 | return { type: 'NULL' }; 116 | }, 117 | }, 118 | CHILD: { 119 | mount: (element, parent, path, snapshot) => { 120 | const tree = createTreeElement('CHILD', parent, path, { 121 | snapshot, 122 | element: element, 123 | value: null, 124 | previous: null, 125 | dirty: false, 126 | hooks: null, 127 | nextHooks: [], 128 | }); 129 | tree.value = tree.root.withGlobalRenderingInstance(tree, () => { 130 | return renderElement(element, tree); 131 | }); 132 | // once mounted, remove snapshot 133 | tree.snapshot = undefined; 134 | return tree; 135 | }, 136 | update: (tree, element) => { 137 | // This is an update of a component 138 | const sameProps = objectShallowEqual(tree.element.props, element.props); 139 | // Note dirty is set when a state or a context change 140 | if (sameProps && tree.dirty === false) { 141 | return tree; 142 | } 143 | // Re-render 144 | tree.value = tree.root.withGlobalRenderingInstance(tree, () => { 145 | return renderElement(element, tree); 146 | }); 147 | // update the tree 148 | tree.element = element; 149 | tree.state = 'updated'; 150 | return tree; 151 | }, 152 | effect: (tree, type) => { 153 | if (tree.hooks) { 154 | tree.hooks.forEach((hook) => { 155 | if (hook.type === type && hook.dirty) { 156 | hook.dirty = false; 157 | hook.cleanup = hook.effect() || undefined; 158 | } 159 | if (hook.type === 'CHILDREN') { 160 | effectInternal(hook.tree, type); 161 | } 162 | }); 163 | } 164 | return; 165 | }, 166 | cleanup: (tree, type, force) => { 167 | if (tree.hooks) { 168 | tree.hooks.forEach((hook) => { 169 | if (hook.type === 'CHILDREN') { 170 | cleanup(hook.tree, type, force); 171 | return; 172 | } 173 | if (hook.type === type && hook.cleanup && (hook.dirty || force)) { 174 | hook.cleanup(); 175 | } 176 | }); 177 | } 178 | }, 179 | access: (instance, path) => { 180 | if (instance.hooks === null) { 181 | return null; 182 | } 183 | const hook = instance.hooks[path.hookIndex]; 184 | if (hook.type !== 'CHILDREN') { 185 | return null; 186 | } 187 | return hook.tree; 188 | }, 189 | snapshot: (instance) => { 190 | return { 191 | type: 'CHILD', 192 | hooks: 193 | instance.hooks === null 194 | ? [] 195 | : instance.hooks.map((hook): HookSnapshot => { 196 | if (hook.type === 'CHILDREN') { 197 | return { type: 'CHILDREN', child: snapshot(hook.tree) }; 198 | } 199 | if (hook.type === 'STATE') { 200 | return { type: 'STATE', value: hook.value }; 201 | } 202 | if (hook.type === 'REDUCER') { 203 | return { type: 'REDUCER', value: hook.value }; 204 | } 205 | return null; 206 | }), 207 | }; 208 | }, 209 | }, 210 | ARRAY: { 211 | mount: (element, parent, path, snapshot) => { 212 | const tree = createTreeElement('ARRAY', parent, path, { 213 | children: [], 214 | value: null, 215 | previous: null, 216 | }); 217 | tree.children = tree.root.withGlobalRenderingInstance(tree, () => { 218 | return element.map((item, index) => { 219 | const snap = snapshot?.children[index]; 220 | return mount(item, tree, { type: 'ARRAY', index }, snap); 221 | }); 222 | }); 223 | tree.value = tree.children.map((item) => item.value); 224 | return tree; 225 | }, 226 | update: (tree, element, path) => { 227 | // if the length is different or if the keys have moved 228 | // we need to create a new TreeElement because cleanup order 229 | // is not the same as effects order 230 | const sameStructure = sameArrayStructure(tree.children, element); 231 | if (sameStructure) { 232 | // same structure just loop through item 233 | // to update them 234 | let updated = false; 235 | tree.children = tree.root.withGlobalRenderingInstance(tree, () => { 236 | return element.map((child, index) => { 237 | const newItem = update(tree.children[index], child, tree, { type: 'ARRAY', index }); 238 | if (updated === false && newItem.state !== 'stable') { 239 | updated = true; 240 | } 241 | return newItem; 242 | }); 243 | }); 244 | tree.state = updated ? 'updated' : 'stable'; 245 | if (updated) { 246 | // Update value 247 | tree.value = tree.children.map((v) => v.value); 248 | } 249 | return tree; 250 | } 251 | // array structure has changed => create a new array TreeElement 252 | const nextTree = createTreeElement('ARRAY', tree.parent, path, { 253 | children: [], 254 | value: null, 255 | previous: tree, 256 | }); 257 | const prevKeys = tree.children.map((item) => getInstanceKey(item)); 258 | nextTree.children = tree.root.withGlobalRenderingInstance(nextTree, () => { 259 | return element.map((item, index) => { 260 | const key = isValidElement(item) ? item.key : undefined; 261 | // search previous item by key first, otherwise by index 262 | const prevIndex = key === undefined ? index : prevKeys.indexOf(key) >= 0 ? prevKeys.indexOf(key) : index; 263 | const prev = tree.children[prevIndex]; 264 | if (!prev) { 265 | return mount(item, nextTree, { type: 'ARRAY', index }, undefined); 266 | } 267 | return update(prev, item, nextTree, { type: 'ARRAY', index }); 268 | }); 269 | }); 270 | nextTree.value = nextTree.children.map((v) => v.value); 271 | // the old tree need to be processed 272 | tree.state = 'updated'; 273 | // mark children not in the new tree as removed 274 | tree.children.forEach((prev) => { 275 | if (nextTree.children.indexOf(prev) < 0) { 276 | prev.state = 'removed'; 277 | } 278 | }); 279 | return nextTree; 280 | }, 281 | effect: (tree, type) => { 282 | tree.children.forEach((child) => { 283 | effectInternal(child, type); 284 | }); 285 | }, 286 | cleanup: (tree, type, force) => { 287 | tree.children.forEach((child) => { 288 | cleanup(child, type, force); 289 | }); 290 | }, 291 | access: (instance, path) => { 292 | return instance.children[path.index] || null; 293 | }, 294 | snapshot: (instance) => { 295 | return { 296 | type: 'ARRAY', 297 | children: instance.children.map((item) => snapshot(item)), 298 | }; 299 | }, 300 | }, 301 | OBJECT: { 302 | mount: (element, parent, path, snapshot) => { 303 | const tree = createTreeElement('OBJECT', parent, path, { 304 | children: {}, 305 | value: null, 306 | previous: null, 307 | }); 308 | tree.children = tree.root.withGlobalRenderingInstance(tree, () => 309 | mapObject(element, (item, key) => { 310 | const snap = snapshot?.children[key]; 311 | return mount(item, tree, { type: 'OBJECT', objectKey: key }, snap); 312 | }), 313 | ); 314 | tree.value = mapObject(tree.children, (v) => v.value); 315 | return tree; 316 | }, 317 | update: (tree, element, path) => { 318 | const sameKeys = sameObjectKeys(element, tree.children); 319 | if (sameKeys) { 320 | // the object has the same structure => update tree object 321 | let updated = false; 322 | tree.root.withGlobalRenderingInstance(tree, () => { 323 | Object.keys(element).forEach((key) => { 324 | const newItem = update(tree.children[key], element[key], tree, { 325 | type: 'OBJECT', 326 | objectKey: key, 327 | }); 328 | if (updated === false && newItem.state !== 'stable') { 329 | updated = true; 330 | } 331 | tree.children[key] = newItem; 332 | }); 333 | }); 334 | tree.state = updated ? 'updated' : 'stable'; 335 | if (updated) { 336 | // Update value 337 | tree.value = mapObject(tree.children, (v) => v.value); 338 | } 339 | return tree; 340 | } 341 | // keys have changed => build new tree 342 | const nextTree = createTreeElement('OBJECT', tree.parent, path, { 343 | children: {}, 344 | value: null, 345 | previous: tree, 346 | }); 347 | const allKeys = new Set([...Object.keys(element), ...Object.keys(tree.children)]); 348 | tree.root.withGlobalRenderingInstance(nextTree, () => { 349 | allKeys.forEach((key) => { 350 | const prev = tree.children[key]; 351 | const next = element[key]; 352 | if (prev && !next) { 353 | // key removed 354 | prev.state = 'removed'; 355 | return; 356 | } 357 | if (!prev && next) { 358 | // key added 359 | nextTree.children[key] = mount(next, nextTree, { type: 'OBJECT', objectKey: key }, undefined); 360 | return; 361 | } 362 | // key updated 363 | const updated = update(prev, next, nextTree, { type: 'OBJECT', objectKey: key }); 364 | nextTree.children[key] = updated; 365 | if (updated !== prev) { 366 | prev.state = 'removed'; 367 | } 368 | }); 369 | }); 370 | nextTree.value = mapObject(nextTree.children, (v) => v.value); 371 | tree.state = 'updated'; 372 | return nextTree; 373 | }, 374 | effect: (tree, type) => { 375 | Object.keys(tree.children).forEach((key) => { 376 | effectInternal(tree.children[key], type); 377 | }); 378 | }, 379 | cleanup: (tree, type, force) => { 380 | if (force === true || tree.state === 'removed') { 381 | Object.keys(tree.children).forEach((key) => { 382 | cleanup(tree.children[key], type, true); 383 | }); 384 | return; 385 | } 386 | Object.keys(tree.children).forEach((key) => { 387 | cleanup(tree.children[key], type, false); 388 | }); 389 | }, 390 | access: (instance, path) => { 391 | return instance.children[path.objectKey] || null; 392 | }, 393 | snapshot: (instance) => { 394 | return { 395 | type: 'OBJECT', 396 | children: mapObject(instance.children, (item) => snapshot(item)), 397 | }; 398 | }, 399 | }, 400 | MAP: { 401 | mount: (element, parent, path, snapshot) => { 402 | const children = mapMap(element, (v, key) => { 403 | const snap = snapshot?.children.get(key); 404 | return mount(v, parent, { type: 'MAP', mapKey: key }, snap); 405 | }); 406 | const item = createTreeElement('MAP', parent, path, { 407 | value: mapMap(children, (item) => item.value), 408 | children, 409 | previous: null, 410 | }); 411 | return item; 412 | }, 413 | update: (tree, element, path) => { 414 | const sameStructure = sameMapStructure(tree.children, element); 415 | if (sameStructure) { 416 | let updated = false; 417 | tree.children = mapMap(element, (child, key) => { 418 | const newItem = update(tree.children.get(key)!, child, tree, { 419 | type: 'MAP', 420 | mapKey: key, 421 | }); 422 | if (updated === false && newItem.state !== 'stable') { 423 | updated = true; 424 | } 425 | return newItem; 426 | }); 427 | tree.state = updated ? 'updated' : 'stable'; 428 | if (updated) { 429 | // Update value 430 | tree.value = mapMap(tree.children, (v) => v.value); 431 | } 432 | return tree; 433 | } 434 | // keys have changed 435 | const nextTree = createTreeElement('MAP', tree.parent, path, { 436 | children: new Map(), 437 | value: null, 438 | previous: tree, 439 | }); 440 | const allKeys = new Set([...Array.from(element.keys()), ...Array.from(tree.children.keys())]); 441 | allKeys.forEach((key) => { 442 | const prev = tree.children.get(key); 443 | const next = element.get(key); 444 | if (prev && !next) { 445 | // key removed 446 | prev.state = 'removed'; 447 | return; 448 | } 449 | if (!prev && next) { 450 | // key added 451 | nextTree.children.set(key, mount(next, nextTree, { type: 'MAP', mapKey: key }, undefined)); 452 | return; 453 | } 454 | if (prev && next) { 455 | // key updated 456 | const updated = update(prev, next, nextTree, { type: 'MAP', mapKey: key }); 457 | nextTree.children.set(key, updated); 458 | if (updated !== prev) { 459 | prev.state = 'removed'; 460 | } 461 | } 462 | }); 463 | nextTree.value = mapMap(nextTree.children, (v) => v.value); 464 | tree.state = 'updated'; 465 | return nextTree; 466 | }, 467 | effect: (tree, type) => { 468 | tree.children.forEach((item) => { 469 | effectInternal(item, type); 470 | }); 471 | }, 472 | cleanup: (tree, effectType, force) => { 473 | if (force === true || tree.state === 'removed') { 474 | tree.children.forEach((item) => { 475 | cleanup(item, effectType, true); 476 | }); 477 | return; 478 | } 479 | tree.children.forEach((item) => { 480 | cleanup(item, effectType, false); 481 | }); 482 | }, 483 | access: (instance, path) => { 484 | return instance.children.get(path.mapKey) || null; 485 | }, 486 | snapshot: (instance) => { 487 | return { 488 | type: 'MAP', 489 | children: mapMap(instance.children, (item) => snapshot(item)), 490 | }; 491 | }, 492 | }, 493 | PROVIDER: { 494 | mount: (element, parent, path, snapshot) => { 495 | const tree = createTreeElement('PROVIDER', parent, path, { 496 | value: null, 497 | previous: null, 498 | element: element, 499 | children: null as any, 500 | }); 501 | tree.children = tree.root.withGlobalRenderingInstance(tree, () => { 502 | return mount(element.props.children, tree, { type: 'PROVIDER' }, snapshot?.children); 503 | }); 504 | tree.value = tree.children.value; 505 | return tree; 506 | }, 507 | update: (tree, element) => { 508 | const shouldMarkDirty = (() => { 509 | if (tree.element.key !== element.key) { 510 | return true; 511 | } 512 | if (tree.element.type.context !== element.type.context) { 513 | return true; 514 | } 515 | if (tree.element.props.value !== element.props.value) { 516 | return true; 517 | } 518 | return false; 519 | })(); 520 | if (shouldMarkDirty) { 521 | markContextSubDirty(tree, tree.element.type.context); 522 | } 523 | tree.element = element; 524 | const children = tree.root.withGlobalRenderingInstance(tree, () => { 525 | return update(tree.children, element.props.children, tree, { type: 'PROVIDER' }); 526 | }); 527 | tree.state = 'updated'; 528 | tree.children = children; 529 | tree.value = children.value; 530 | return tree; 531 | }, 532 | effect: (tree, type) => { 533 | effectInternal(tree.children, type); 534 | }, 535 | cleanup: (tree, type, force) => { 536 | if (force === true || tree.state === 'removed') { 537 | cleanup(tree.children, type, true); 538 | return; 539 | } 540 | cleanup(tree.children, type, false); 541 | }, 542 | access: (instance) => { 543 | return instance.children; 544 | }, 545 | snapshot: (instance) => { 546 | return { type: 'PROVIDER', children: snapshot(instance.children) }; 547 | }, 548 | }, 549 | }; 550 | 551 | function access(instance: TreeElement, paths: Array): TreeElement | null { 552 | return paths.reduce((acc, path) => { 553 | if (acc === null) { 554 | return null; 555 | } 556 | return CHILDREN_LIFECYCLES[acc.type].access(acc as any, path as any); 557 | }, instance); 558 | } 559 | 560 | function snapshot(instance: TreeElement): TreeElementSnapshot { 561 | return CHILDREN_LIFECYCLES[instance.type].snapshot(instance as any) as any; 562 | } 563 | 564 | function mount( 565 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 566 | element: any, 567 | parent: TreeElement, 568 | path: TreeElementPath, 569 | snapshot: TreeElementSnapshot | undefined, 570 | ): TreeElement { 571 | return CHILDREN_LIFECYCLES[getChildrenType(element)].mount(element as never, parent, path, snapshot as any); 572 | } 573 | 574 | /** 575 | * Returns 576 | * - the same reference if the structure is the same 577 | * - or a new reference if the struture has changed 578 | */ 579 | function update( 580 | instance: TreeElement, 581 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 582 | element: any, 583 | parent: TreeElement, 584 | path: TreeElementPath, 585 | ): TreeElement { 586 | if ((parent === null || path === null) && instance.type !== 'ROOT') { 587 | throw new Error('Oops'); 588 | } 589 | 590 | const nextType = getChildrenType(element); 591 | const shouldUnmoutRemount = (() => { 592 | if (instance.type !== nextType) { 593 | return true; 594 | } 595 | if (instance.type === 'CHILD' && isComponentElement(element)) { 596 | if (instance.element.type !== element.type) { 597 | // different component 598 | return true; 599 | } 600 | } 601 | if (isElementInstance(instance) && isValidElement(element)) { 602 | if (element.key !== instance.element.key) { 603 | // different key 604 | return true; 605 | } 606 | } 607 | return false; 608 | })(); 609 | 610 | if (shouldUnmoutRemount) { 611 | // we mount the new children and flag the old one as removed 612 | const nextTree = mount(element, parent, path, undefined); 613 | instance.state = 'removed'; 614 | nextTree.previous = instance; 615 | return nextTree; 616 | } 617 | 618 | const updated = CHILDREN_LIFECYCLES[instance.type].update(instance as any, element as never, path); 619 | updated.parent = parent; 620 | updated.path = path; 621 | return updated; 622 | } 623 | 624 | function effectInternal(tree: TreeElement, effecType: EffectType) { 625 | const state = tree.state; 626 | if (state === 'stable' || state === 'removed') { 627 | return; 628 | } 629 | if (!tree.root.passiveMode) { 630 | CHILDREN_LIFECYCLES[tree.type].effect(tree as any, effecType); 631 | } 632 | if (effecType === 'EFFECT') { 633 | // once effect is done, the tree is stable 634 | tree.state = 'stable'; 635 | } 636 | } 637 | 638 | function unmount(tree: TreeElement): void { 639 | cleanup(tree, 'LAYOUT_EFFECT', true); 640 | cleanup(tree, 'EFFECT', true); 641 | } 642 | 643 | function effects(tree: TreeElement): void { 644 | cleanup(tree, 'EFFECT', false); 645 | effectInternal(tree, 'EFFECT'); 646 | } 647 | 648 | function layoutEffects(tree: TreeElement): void { 649 | cleanup(tree, 'LAYOUT_EFFECT', false); 650 | effectInternal(tree, 'LAYOUT_EFFECT'); 651 | } 652 | 653 | function cleanupTree(tree: TreeElement, effecType: EffectType, force: boolean) { 654 | const doForce = tree.state === 'removed' ? true : force; 655 | CHILDREN_LIFECYCLES[tree.type].cleanup(tree as any, effecType, doForce); 656 | } 657 | 658 | function cleanup(tree: TreeElement, effecType: EffectType, force: boolean) { 659 | if (tree.previous) { 660 | cleanupTree(tree.previous, effecType, force); 661 | if (effecType === 'EFFECT' && tree.previous) { 662 | // if we cleanup effects we don't need this anymore 663 | tree.previous = null; 664 | } 665 | } 666 | if (tree.state === 'created') { 667 | // no need to cleanup 668 | return; 669 | } 670 | cleanupTree(tree, effecType, force); 671 | } 672 | 673 | function renderElement(element: ElementComponent, instance: TreeElement<'CHILD'>): T { 674 | // clear hooks 675 | instance.nextHooks = []; 676 | const result = element.type(element.props); 677 | // make sure rule of hooks is respected 678 | if (instance.hooks && instance.hooks.length !== instance.nextHooks.length) { 679 | throw new Error('Hooks count mismatch !'); 680 | } 681 | // update context sub 682 | const allContexts = new Set>(); 683 | const prevContexts: Set> = 684 | instance.hooks === null 685 | ? new Set() 686 | : instance.hooks.reduce((acc, hook) => { 687 | if (hook.type === 'CONTEXT') { 688 | allContexts.add(hook.context); 689 | acc.add(hook.context); 690 | } 691 | return acc; 692 | }, new Set>()); 693 | const nextContexts = instance.nextHooks.reduce((acc, hook) => { 694 | if (hook.type === 'CONTEXT') { 695 | allContexts.add(hook.context); 696 | acc.add(hook.context); 697 | } 698 | return acc; 699 | }, new Set>()); 700 | allContexts.forEach((c) => { 701 | if (prevContexts.has(c) && !nextContexts.has(c)) { 702 | unregisterContextSub(instance, c); 703 | } 704 | if (!prevContexts.has(c) && nextContexts.has(c)) { 705 | registerContextSub(instance, c); 706 | } 707 | }); 708 | instance.hooks = instance.nextHooks; 709 | instance.dirty = false; 710 | return result; 711 | } 712 | 713 | function getChildrenType(element: any): TreeElementType { 714 | if (element === null) { 715 | return 'NULL'; 716 | } 717 | if (isValidElement(element)) { 718 | if (isRootElement(element)) { 719 | return 'ROOT'; 720 | } 721 | if (isComponentElement(element)) { 722 | return 'CHILD'; 723 | } 724 | if (isProviderElement(element)) { 725 | return 'PROVIDER'; 726 | } 727 | // if (isConsumerElement(element)) { 728 | // return 'CONSUMER'; 729 | // } 730 | throw new Error(`Invalid children element type`); 731 | } 732 | if (Array.isArray(element)) { 733 | return 'ARRAY'; 734 | } 735 | if (element instanceof Map) { 736 | return 'MAP'; 737 | } 738 | if (isPlainObject(element)) { 739 | return 'OBJECT'; 740 | } 741 | if (element instanceof Set) { 742 | throw new Error('Set are not supported'); 743 | // return 'SET'; 744 | } 745 | throw new Error(`Invalid children type`); 746 | } 747 | -------------------------------------------------------------------------------- /src/Global.ts: -------------------------------------------------------------------------------- 1 | import type { TreeElement } from './types'; 2 | 3 | let GLOBAL_STATE: TreeElement<'ROOT'> | null = null; 4 | 5 | export function getCurrentRootInstance(): TreeElement<'ROOT'> { 6 | if (GLOBAL_STATE === null) { 7 | throw new Error('Calling hook outside of component'); 8 | } 9 | return GLOBAL_STATE; 10 | } 11 | 12 | export function setCurrentRootInstance(instance: TreeElement<'ROOT'> | null): void { 13 | GLOBAL_STATE = instance; 14 | } 15 | -------------------------------------------------------------------------------- /src/Hooks.ts: -------------------------------------------------------------------------------- 1 | import { ChildrenUtils } from './ChildrenUtils'; 2 | import { getCurrentRootInstance } from './Global'; 3 | import { DEMOCRAT_CONTEXT } from './symbols'; 4 | import type { 5 | AnyFn, 6 | Children, 7 | Context, 8 | ContextHookData, 9 | DependencyList, 10 | Dispatch, 11 | DispatchWithoutAction, 12 | EffectCallback, 13 | EffectHookData, 14 | EffectType, 15 | LayoutEffectHookData, 16 | MemoHookData, 17 | MutableRefObject, 18 | Reducer, 19 | ReducerAction, 20 | ReducerHookData, 21 | ReducerPatch, 22 | ReducerState, 23 | ReducerStateWithoutAction, 24 | ReducerWithoutAction, 25 | RefHookData, 26 | ResolveType, 27 | SetStateAction, 28 | StateHookData, 29 | StatePatch, 30 | TreeElementPath, 31 | } from './types'; 32 | import { depsChanged, getPatchPath } from './utils'; 33 | 34 | export function useChildren(children: C): ResolveType { 35 | const root = getCurrentRootInstance(); 36 | const hook = root.getCurrentHook(); 37 | 38 | if (hook === null) { 39 | const instance = root.getCurrentRenderingChildInstance(); 40 | const hookIndex = root.getCurrentHookIndex(); 41 | const snapshot = instance.snapshot?.hooks[hookIndex]; 42 | const snap = snapshot && snapshot.type === 'CHILDREN' ? snapshot.child : undefined; 43 | const path: TreeElementPath<'CHILD'> = { type: 'CHILD', hookIndex }; 44 | const childrenTree = ChildrenUtils.mount(children, instance, path, snap); 45 | root.setCurrentHook({ 46 | type: 'CHILDREN', 47 | tree: childrenTree, 48 | path, 49 | }); 50 | return childrenTree.value; 51 | } 52 | if (hook.type !== 'CHILDREN') { 53 | throw new Error('Invalid Hook type'); 54 | } 55 | hook.tree = ChildrenUtils.update(hook.tree, children, hook.tree.parent, hook.path); 56 | root.setCurrentHook(hook); 57 | return hook.tree.value; 58 | } 59 | 60 | // overload where dispatch could accept 0 arguments. 61 | export function useReducer, I>( 62 | reducer: R, 63 | initializerArg: I, 64 | initializer: (arg: I) => ReducerStateWithoutAction, 65 | ): [ReducerStateWithoutAction, DispatchWithoutAction]; 66 | // overload where dispatch could accept 0 arguments. 67 | export function useReducer>( 68 | reducer: R, 69 | initializerArg: ReducerStateWithoutAction, 70 | initializer?: undefined, 71 | ): [ReducerStateWithoutAction, DispatchWithoutAction]; 72 | // overload where "I" may be a subset of ReducerState; used to provide autocompletion. 73 | // If "I" matches ReducerState exactly then the last overload will allow initializer to be ommitted. 74 | // the last overload effectively behaves as if the identity function (x => x) is the initializer. 75 | export function useReducer, I>( 76 | reducer: R, 77 | initializerArg: I & ReducerState, 78 | initializer: (arg: I & ReducerState) => ReducerState, 79 | ): [ReducerState, Dispatch>]; 80 | // overload for free "I"; all goes as long as initializer converts it into "ReducerState". 81 | export function useReducer, I>( 82 | reducer: R, 83 | initializerArg: I, 84 | initializer: (arg: I) => ReducerState, 85 | ): [ReducerState, Dispatch>]; 86 | export function useReducer>( 87 | reducer: R, 88 | initialState: ReducerState, 89 | initializer?: undefined, 90 | ): [ReducerState, Dispatch>]; 91 | // implementation 92 | export function useReducer(reducer: any, initialArg: any, init?: any): [any, Dispatch] { 93 | const root = getCurrentRootInstance(); 94 | const hook = root.getCurrentHook(); 95 | if (hook === null) { 96 | const instance = root.getCurrentRenderingChildInstance(); 97 | let initialState; 98 | if (init !== undefined) { 99 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 100 | initialState = init(initialArg); 101 | } else { 102 | initialState = initialArg; 103 | } 104 | const hookIndex = root.getCurrentHookIndex(); 105 | const snapshot = instance.snapshot?.hooks[hookIndex]; 106 | if (snapshot && snapshot.type === 'REDUCER') { 107 | initialState = snapshot.value; 108 | } 109 | const value = initialState; 110 | const dispatch: Dispatch = (action) => { 111 | if (instance.state === 'removed') { 112 | throw new Error(`Cannot dispatch on an unmounted component`); 113 | } 114 | instance.root.onIdle(() => { 115 | const nextValue = reducerHook.reducer(reducerHook.value, action); 116 | if (nextValue !== reducerHook.value) { 117 | const patch: ReducerPatch = { 118 | path: getPatchPath(instance), 119 | type: 'REDUCER', 120 | hookIndex, 121 | action, 122 | }; 123 | reducerHook.value = nextValue; 124 | root.markDirty(instance); 125 | instance.root.requestRender(patch); 126 | } 127 | }); 128 | }; 129 | const reducerHook: ReducerHookData = { type: 'REDUCER', value, dispatch, reducer }; 130 | root.setCurrentHook(reducerHook); 131 | return [value, dispatch]; 132 | } 133 | if (hook.type !== 'REDUCER') { 134 | throw new Error('Invalid Hook type'); 135 | } 136 | hook.reducer = reducer; 137 | root.setCurrentHook(hook); 138 | return [hook.value, hook.dispatch]; 139 | } 140 | 141 | export function useState(initialState: S | (() => S)): [S, Dispatch>] { 142 | const root = getCurrentRootInstance(); 143 | const hook = root.getCurrentHook(); 144 | if (hook === null) { 145 | const instance = root.getCurrentRenderingChildInstance(); 146 | const hookIndex = root.getCurrentHookIndex(); 147 | let value = typeof initialState === 'function' ? (initialState as AnyFn)() : initialState; 148 | const snapshot = instance.snapshot?.hooks[hookIndex]; 149 | if (snapshot && snapshot.type === 'STATE') { 150 | value = snapshot.value; 151 | } 152 | const setValue: Dispatch> = (value) => { 153 | if (instance.state === 'removed') { 154 | throw new Error(`Cannot set state of an unmounted component`); 155 | } 156 | instance.root.onIdle(() => { 157 | const nextValue = typeof value === 'function' ? (value as AnyFn)(stateHook.value) : value; 158 | if (nextValue !== stateHook.value) { 159 | const patch: StatePatch = { 160 | path: getPatchPath(instance), 161 | type: 'STATE', 162 | hookIndex, 163 | value: nextValue, 164 | }; 165 | stateHook.value = nextValue; 166 | root.markDirty(instance); 167 | instance.root.requestRender(patch); 168 | } 169 | }); 170 | }; 171 | const stateHook: StateHookData = { type: 'STATE', value, setValue }; 172 | root.setCurrentHook(stateHook); 173 | return [value, setValue]; 174 | } 175 | if (hook.type !== 'STATE') { 176 | throw new Error('Invalid Hook type'); 177 | } 178 | root.setCurrentHook(hook); 179 | return [hook.value, hook.setValue]; 180 | } 181 | 182 | export function useEffect(effect: EffectCallback, deps?: DependencyList): void { 183 | return useEffectInternal('EFFECT', effect, deps); 184 | } 185 | 186 | export function useLayoutEffect(effect: EffectCallback, deps?: DependencyList): void { 187 | return useEffectInternal('LAYOUT_EFFECT', effect, deps); 188 | } 189 | 190 | function useEffectInternal(effecType: EffectType, effect: EffectCallback, deps?: DependencyList): void { 191 | const root = getCurrentRootInstance(); 192 | const hook = root.getCurrentHook(); 193 | if (hook === null) { 194 | const effectHook: EffectHookData | LayoutEffectHookData = { 195 | type: effecType, 196 | effect, 197 | cleanup: undefined, 198 | deps, 199 | dirty: true, 200 | }; 201 | root.setCurrentHook(effectHook); 202 | return; 203 | } 204 | if (hook.type !== effecType) { 205 | throw new Error('Invalid Hook type'); 206 | } 207 | if (depsChanged(hook.deps, deps)) { 208 | hook.effect = effect; 209 | hook.deps = deps; 210 | hook.dirty = true; 211 | root.setCurrentHook(hook); 212 | return; 213 | } 214 | // ignore this effect 215 | root.setCurrentHook(hook); 216 | return; 217 | } 218 | 219 | export function useMemo(factory: () => T, deps: DependencyList | undefined): T { 220 | const root = getCurrentRootInstance(); 221 | const hook = root.getCurrentHook(); 222 | if (hook === null) { 223 | const memoHook: MemoHookData = { 224 | type: 'MEMO', 225 | value: factory(), 226 | deps, 227 | }; 228 | root.setCurrentHook(memoHook); 229 | return memoHook.value; 230 | } 231 | if (hook.type !== 'MEMO') { 232 | throw new Error('Invalid Hook type'); 233 | } 234 | if (depsChanged(hook.deps, deps)) { 235 | hook.deps = deps; 236 | hook.value = factory(); 237 | } 238 | root.setCurrentHook(hook); 239 | return hook.value; 240 | } 241 | 242 | export function useCallback unknown>(callback: T, deps: DependencyList): T { 243 | // eslint-disable-next-line react-hooks/exhaustive-deps 244 | return useMemo(() => callback, deps); 245 | } 246 | 247 | export function useRef(initialValue: T): MutableRefObject; 248 | export function useRef(): MutableRefObject; 249 | export function useRef(initialValue?: T): MutableRefObject { 250 | const root = getCurrentRootInstance(); 251 | const hook = root.getCurrentHook(); 252 | if (hook === null) { 253 | const memoHook: RefHookData = { 254 | type: 'REF', 255 | ref: { 256 | current: initialValue, 257 | }, 258 | }; 259 | root.setCurrentHook(memoHook); 260 | return memoHook.ref; 261 | } 262 | if (hook.type !== 'REF') { 263 | throw new Error('Invalid Hook type'); 264 | } 265 | root.setCurrentHook(hook); 266 | return hook.ref; 267 | } 268 | 269 | function useContextInternal>( 270 | context: C, 271 | ): { found: C[typeof DEMOCRAT_CONTEXT]['defaultValue']; value: any } { 272 | const root = getCurrentRootInstance(); 273 | const hook = root.getCurrentHook(); 274 | if (hook !== null) { 275 | if (hook.type !== 'CONTEXT') { 276 | throw new Error('Invalid Hook type'); 277 | } 278 | } 279 | // TODO: move provider and value resolution to the instance level 280 | const provider = root.findProvider(context); 281 | const value = provider 282 | ? provider.element.props.value 283 | : context[DEMOCRAT_CONTEXT].hasDefault 284 | ? context[DEMOCRAT_CONTEXT].defaultValue 285 | : undefined; 286 | const contextHook: ContextHookData = { 287 | type: 'CONTEXT', 288 | context, 289 | provider, 290 | value, 291 | }; 292 | root.setCurrentHook(contextHook); 293 | return { 294 | found: provider !== null, 295 | value, 296 | }; 297 | } 298 | 299 | export function useContext>( 300 | context: C, 301 | ): C[typeof DEMOCRAT_CONTEXT]['hasDefault'] extends false 302 | ? C[typeof DEMOCRAT_CONTEXT]['defaultValue'] | undefined 303 | : C[typeof DEMOCRAT_CONTEXT]['defaultValue'] { 304 | return useContextInternal(context).value; 305 | } 306 | 307 | /** 308 | * Same as useContext except if there are no provider and no default value it throw an error 309 | */ 310 | export function useContextOrThrow>(context: C): C[typeof DEMOCRAT_CONTEXT]['defaultValue'] { 311 | const { found, value } = useContextInternal(context); 312 | if (found === false && context[DEMOCRAT_CONTEXT].hasDefault === false) { 313 | throw new Error('Missing Provider'); 314 | } 315 | return value; 316 | } 317 | -------------------------------------------------------------------------------- /src/Store.ts: -------------------------------------------------------------------------------- 1 | import type { TUnsubscribe } from '@dldc/pubsub'; 2 | import { createSubscription, createVoidSubscription } from '@dldc/pubsub'; 3 | import { ChildrenUtils } from './ChildrenUtils'; 4 | import { setCurrentRootInstance } from './Global'; 5 | import * as Hooks from './Hooks'; 6 | import { useChildren } from './Hooks'; 7 | import { DEMOCRAT_COMPONENT, DEMOCRAT_ELEMENT, DEMOCRAT_ROOT } from './symbols'; 8 | import type { 9 | AnyFn, 10 | Children, 11 | DemocratRootElement, 12 | Factory, 13 | FunctionComponent, 14 | GenericFactory, 15 | Key, 16 | OnIdleExec, 17 | Patch, 18 | Patches, 19 | ResolveType, 20 | Snapshot, 21 | TreeElement, 22 | } from './types'; 23 | import type { Timer } from './utils'; 24 | import { createElement, createRootTreeElement } from './utils'; 25 | 26 | export { createContext, createElement, isValidElement } from './utils'; 27 | 28 | export interface Store { 29 | render: (rootChildren: C) => void; 30 | getState: () => S; 31 | subscribe: (onChange: () => void) => TUnsubscribe; 32 | destroy: () => void; 33 | // patches 34 | subscribePatches: (onPatches: (patches: Patches) => void) => TUnsubscribe; 35 | applyPatches: (patches: Patches) => void; 36 | // snapshot 37 | getSnapshot: () => Snapshot; 38 | } 39 | 40 | export interface CreateStoreOptions { 41 | // pass an instance of React to override hooks 42 | ReactInstance?: any; // null | typeof React 43 | // In passive mode, effect are never executed 44 | passiveMode?: boolean; 45 | // restore a snapshot 46 | snapshot?: Snapshot; 47 | } 48 | 49 | export function createFactory

(fn: FunctionComponent): Factory { 50 | return { 51 | [DEMOCRAT_COMPONENT]: true, 52 | Component: fn, 53 | createElement: ((props: any, key: Key) => { 54 | return createElement(fn, props, key); 55 | }) as any, 56 | useChildren: ((props: any, key: Key) => { 57 | return useChildren(createElement(fn, props, key)); 58 | }) as any, 59 | }; 60 | } 61 | 62 | export function createGenericFactory>(fn: Fn): GenericFactory { 63 | return { 64 | [DEMOCRAT_COMPONENT]: true, 65 | Component: fn, 66 | createElement: ((runner: AnyFn, key: Key) => { 67 | return runner((props: any) => createElement(fn, props, key)); 68 | }) as any, 69 | useChildren: ((runner: AnyFn, key: Key) => { 70 | return useChildren(runner((props: any) => createElement(fn, props, key))); 71 | }) as any, 72 | }; 73 | } 74 | 75 | export function createStore( 76 | rootChildren: C, 77 | options: CreateStoreOptions = {}, 78 | ): Store> { 79 | const { ReactInstance = null, passiveMode = false, snapshot } = options; 80 | 81 | const stateSub = createVoidSubscription(); 82 | const patchesSub = createSubscription(); 83 | 84 | let state: ResolveType; 85 | let destroyed: boolean = false; 86 | let execQueue: null | Array = null; 87 | let renderRequested = false; 88 | let flushScheduled = false; 89 | let patchesQueue: Patches = []; 90 | let effectTimer: Timer | undefined = undefined; 91 | 92 | const rootElem: DemocratRootElement = { 93 | [DEMOCRAT_ELEMENT]: true, 94 | [DEMOCRAT_ROOT]: true, 95 | children: rootChildren, 96 | }; 97 | 98 | let rootInstance: TreeElement<'ROOT'> = createRootTreeElement({ 99 | onIdle, 100 | requestRender, 101 | applyPatches, 102 | passiveMode, 103 | }); 104 | 105 | if (ReactInstance) { 106 | rootInstance.supportReactHooks(ReactInstance, Hooks); 107 | } 108 | 109 | doRender(); 110 | 111 | return { 112 | render, 113 | getState: () => state, 114 | subscribe: stateSub.subscribe, 115 | destroy, 116 | subscribePatches: patchesSub.subscribe, 117 | applyPatches, 118 | getSnapshot, 119 | }; 120 | 121 | function render(newRootChildren: C) { 122 | onIdle(() => { 123 | rootElem.children = newRootChildren; 124 | requestRender(null); 125 | }); 126 | } 127 | 128 | function getSnapshot(): Snapshot { 129 | return ChildrenUtils.snapshot<'ROOT'>(rootInstance); 130 | } 131 | 132 | function doRender(): void { 133 | if (destroyed) { 134 | throw new Error('Store destroyed'); 135 | } 136 | setCurrentRootInstance(rootInstance); 137 | if (rootInstance.mounted === false) { 138 | rootInstance = ChildrenUtils.mount(rootElem, rootInstance, null as any, snapshot) as any; 139 | } else { 140 | rootInstance = ChildrenUtils.update(rootInstance, rootElem, null as any, null as any) as any; 141 | } 142 | setCurrentRootInstance(null); 143 | state = rootInstance.value; 144 | // Schedule setTimeout(() => runEffect) 145 | effectTimer = scheduleEffects(); 146 | // run layoutEffects 147 | ChildrenUtils.layoutEffects(rootInstance); 148 | // Apply all `setState` 149 | const layoutEffectsRequestRender = flushExecQueue(); 150 | if (layoutEffectsRequestRender) { 151 | // cancel the setTimeout 152 | globalThis.clearTimeout(effectTimer); 153 | // run effect synchronously 154 | ChildrenUtils.effects(rootInstance); 155 | // apply setState 156 | flushExecQueue(); 157 | doRender(); 158 | } else { 159 | // not setState in Layout effect 160 | stateSub.emit(); 161 | if (patchesQueue.length > 0) { 162 | const patches = patchesQueue; 163 | patchesQueue = []; 164 | patchesSub.emit(patches); 165 | } 166 | return; 167 | } 168 | } 169 | 170 | function onIdle(exec: OnIdleExec) { 171 | if (destroyed) { 172 | throw new Error('Store destroyed'); 173 | } 174 | if (rootInstance.isRendering()) { 175 | throw new Error(`Cannot setState during render !`); 176 | } 177 | if (execQueue === null) { 178 | execQueue = [exec]; 179 | } else { 180 | execQueue.push(exec); 181 | } 182 | scheduleFlush(); 183 | } 184 | 185 | function scheduleFlush(): void { 186 | if (flushScheduled) { 187 | return; 188 | } 189 | flushScheduled = true; 190 | globalThis.setTimeout(() => { 191 | flushScheduled = false; 192 | const shouldRender = flushExecQueue(); 193 | if (shouldRender) { 194 | doRender(); 195 | } 196 | }, 0); 197 | } 198 | 199 | function requestRender(patch: Patch | null): void { 200 | if (patch) { 201 | patchesQueue.push(patch); 202 | } 203 | renderRequested = true; 204 | } 205 | 206 | function flushExecQueue(): boolean { 207 | renderRequested = false; 208 | if (execQueue) { 209 | execQueue.forEach((exec) => { 210 | exec(); 211 | }); 212 | execQueue = null; 213 | } 214 | return renderRequested; 215 | } 216 | 217 | function scheduleEffects(): Timer { 218 | return globalThis.setTimeout(() => { 219 | ChildrenUtils.effects(rootInstance); 220 | const shouldRender = flushExecQueue(); 221 | if (shouldRender) { 222 | doRender(); 223 | } 224 | }, 0); 225 | } 226 | 227 | function applyPatches(patches: Patches) { 228 | rootInstance.onIdle(() => { 229 | patches.forEach((patch) => { 230 | const instance = ChildrenUtils.access(rootInstance, patch.path); 231 | if (instance === null || instance.type !== 'CHILD' || instance.hooks === null) { 232 | return; 233 | } 234 | const hook = instance.hooks[patch.hookIndex]; 235 | if (!hook || hook.type !== patch.type) { 236 | return; 237 | } 238 | // Should we use setValue / dispath ? If yes we need to prevent from re-emitting the patch 239 | // hook.setValue(patch.value) 240 | if (patch.type === 'STATE' && hook.type === 'STATE') { 241 | hook.value = patch.value; 242 | } 243 | if (patch.type === 'REDUCER' && hook.type === 'REDUCER') { 244 | hook.value = hook.reducer(hook.value, patch.action); 245 | } 246 | rootInstance.markDirty(instance); 247 | instance.root.requestRender(null); 248 | }); 249 | }); 250 | } 251 | 252 | function destroy() { 253 | if (destroyed) { 254 | throw new Error('Store already destroyed'); 255 | } 256 | if (effectTimer !== null) { 257 | globalThis.clearTimeout(effectTimer); 258 | } 259 | ChildrenUtils.unmount(rootInstance); 260 | destroyed = true; 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /src/mod.ts: -------------------------------------------------------------------------------- 1 | export { 2 | useCallback, 3 | useChildren, 4 | useContext, 5 | useContextOrThrow, 6 | useEffect, 7 | useLayoutEffect, 8 | useMemo, 9 | useReducer, 10 | useRef, 11 | useState, 12 | } from './Hooks'; 13 | export { 14 | createContext, 15 | createElement, 16 | createFactory, 17 | createGenericFactory, 18 | createStore, 19 | isValidElement, 20 | type CreateStoreOptions, 21 | type Store, 22 | } from './Store'; 23 | 24 | export type { 25 | Children, 26 | Context, 27 | DependencyList, 28 | Dispatch, 29 | DispatchWithoutAction, 30 | EffectCallback, 31 | EffectCleanup, 32 | Element, 33 | ElementComponent, 34 | ElementProvider, 35 | Factory, 36 | FunctionComponent, 37 | GenericFactory, 38 | HookSnapshot, 39 | Key, 40 | MutableRefObject, 41 | Patch, 42 | Patches, 43 | Reducer, 44 | ReducerAction, 45 | ReducerPatch, 46 | ReducerState, 47 | ReducerStateWithoutAction, 48 | ReducerWithoutAction, 49 | ResolveType, 50 | SetStateAction, 51 | Snapshot, 52 | StatePatch, 53 | TreeElementPath, 54 | TreeElementSnapshot, 55 | } from './types'; 56 | -------------------------------------------------------------------------------- /src/symbols.ts: -------------------------------------------------------------------------------- 1 | export const DEMOCRAT_ELEMENT = Symbol('DEMOCRAT_ELEMENT'); 2 | export const DEMOCRAT_COMPONENT = Symbol('DEMOCRAT_COMPONENT'); 3 | export const DEMOCRAT_CONTEXT = Symbol('DEMOCRAT_CONTEXT'); 4 | export const DEMOCRAT_ROOT = Symbol('DEMOCRAT_CONTEXT'); 5 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | /* istanbul ignore next */ 3 | import type { DEMOCRAT_COMPONENT, DEMOCRAT_CONTEXT, DEMOCRAT_ELEMENT, DEMOCRAT_ROOT } from './symbols'; 4 | 5 | export type Dispatch = (value: A) => void; 6 | export type SetStateAction = S | ((prevState: S) => S); 7 | export type DependencyList = ReadonlyArray; 8 | export type EffectCleanup = () => void | undefined; 9 | export type EffectCallback = () => void | EffectCleanup; 10 | 11 | export type AnyFn = (...args: any[]) => any; 12 | 13 | export interface MutableRefObject { 14 | current: T; 15 | } 16 | 17 | export type EffectType = 'EFFECT' | 'LAYOUT_EFFECT'; 18 | 19 | export interface StatePatch { 20 | path: Array; 21 | type: 'STATE'; 22 | hookIndex: number; 23 | value: any; 24 | } 25 | 26 | export interface ReducerPatch { 27 | path: Array; 28 | type: 'REDUCER'; 29 | hookIndex: number; 30 | action: any; 31 | } 32 | 33 | export type Patch = StatePatch | ReducerPatch; 34 | 35 | export type Patches = Array; 36 | 37 | export type Key = string | number | undefined; 38 | 39 | export interface DemocratContextProvider

{ 40 | [DEMOCRAT_CONTEXT]: 'PROVIDER'; 41 | context: Context

; 42 | createElement: (props: ContextProviderProps, key?: Key) => ElementProvider>; 43 | } 44 | 45 | export type ContextProviderProps = { 46 | value: P; 47 | children: T; 48 | }; 49 | 50 | export type FunctionComponent = (props: P) => T; 51 | 52 | export type Factory = { 53 | Component: FunctionComponent; 54 | [DEMOCRAT_COMPONENT]: true; 55 | createElement: P extends void 56 | ? (props?: undefined | Record, key?: Key) => ElementComponent 57 | : (props: P, key?: Key) => ElementComponent; 58 | useChildren: P extends void 59 | ? (props?: undefined | Record, key?: Key) => T 60 | : (props: P, key?: Key) => T; 61 | }; 62 | 63 | export type GenericFactory> = { 64 | Component: Fn; 65 | [DEMOCRAT_COMPONENT]: true; 66 | createElement: (runner: (create: Fn) => R, key?: Key) => ElementComponent; 67 | useChildren: (runner: (create: Fn) => R, key?: Key) => R; 68 | }; 69 | 70 | export type AnyProps = { [key: string]: any }; 71 | 72 | export interface ElementComponent { 73 | [DEMOCRAT_ELEMENT]: true; 74 | type: FunctionComponent; 75 | props: AnyProps; 76 | key: Key; 77 | } 78 | 79 | export interface ElementProvider { 80 | [DEMOCRAT_ELEMENT]: true; 81 | type: DemocratContextProvider; 82 | props: ContextProviderProps; 83 | key: Key; 84 | } 85 | 86 | export type Element = ElementComponent | ElementProvider; 87 | 88 | export interface DemocratRootElement { 89 | [DEMOCRAT_ELEMENT]: true; 90 | [DEMOCRAT_ROOT]: true; 91 | children: Children; 92 | } 93 | 94 | export interface Context { 95 | [DEMOCRAT_CONTEXT]: { 96 | hasDefault: HasDefault; 97 | defaultValue: T; 98 | }; 99 | Provider: DemocratContextProvider; 100 | } 101 | 102 | export type Children = Element | null | Array | Map | { [key: string]: Children }; 103 | 104 | export type ResolveType = 105 | C extends Element 106 | ? T 107 | : C extends null 108 | ? null 109 | : C extends Array 110 | ? Array> 111 | : C extends Map 112 | ? Map> 113 | : C extends { [key: string]: Children } 114 | ? { [K in keyof C]: ResolveType } 115 | : never; 116 | 117 | export interface StateHookData { 118 | type: 'STATE'; 119 | value: any; 120 | setValue: Dispatch>; 121 | } 122 | 123 | export interface ReducerHookData { 124 | type: 'REDUCER'; 125 | value: any; 126 | dispatch: Dispatch>; 127 | reducer: Reducer; 128 | } 129 | 130 | export interface ChildrenHookData { 131 | type: 'CHILDREN'; 132 | tree: TreeElement; 133 | path: TreeElementPath<'CHILD'>; 134 | } 135 | 136 | export type RefHookData = { 137 | type: 'REF'; 138 | ref: MutableRefObject; 139 | }; 140 | 141 | export type EffectHookData = { 142 | type: 'EFFECT'; 143 | effect: EffectCallback; 144 | cleanup: undefined | EffectCleanup; 145 | deps: DependencyList | undefined; 146 | dirty: boolean; 147 | }; 148 | 149 | export type LayoutEffectHookData = { 150 | type: 'LAYOUT_EFFECT'; 151 | effect: EffectCallback; 152 | cleanup: undefined | EffectCleanup; 153 | deps: DependencyList | undefined; 154 | dirty: boolean; 155 | }; 156 | 157 | export type MemoHookData = { 158 | type: 'MEMO'; 159 | value: any; 160 | deps: DependencyList | undefined; 161 | }; 162 | 163 | export type ContextHookData = { 164 | type: 'CONTEXT'; 165 | context: Context; 166 | provider: TreeElement<'PROVIDER'> | null; 167 | value: any; 168 | }; 169 | 170 | export type HooksData = 171 | | StateHookData 172 | | ReducerHookData 173 | | ChildrenHookData 174 | | EffectHookData 175 | | MemoHookData 176 | | LayoutEffectHookData 177 | | RefHookData 178 | | ContextHookData; 179 | 180 | export type OnIdleExec = () => void; 181 | export type OnIdle = (exec: OnIdleExec) => void; 182 | 183 | export type TreeElementState = 'created' | 'stable' | 'updated' | 'removed'; 184 | 185 | export type TreeElementCommon = { 186 | id: number; 187 | parent: TreeElement; 188 | path: TreeElementPath; 189 | // when structure change we keep the previous one to cleanup 190 | previous: TreeElement | null; 191 | value: any; 192 | state: TreeElementState; 193 | root: TreeElement<'ROOT'>; 194 | }; 195 | 196 | export type TreeElementData = { 197 | ROOT: { 198 | onIdle: OnIdle; 199 | mounted: boolean; 200 | passiveMode: boolean; 201 | children: TreeElement; 202 | context: Map, Set>>; 203 | requestRender: (pathch: Patch | null) => void; 204 | supportReactHooks: (ReactInstance: any, Hooks: any) => void; 205 | isRendering: () => boolean; 206 | applyPatches: (patches: Patches) => void; 207 | findProvider: (context: Context) => TreeElement<'PROVIDER'> | null; 208 | markDirty: (instance: TreeElement<'CHILD'>, limit?: TreeElement | null) => void; 209 | withGlobalRenderingInstance: (current: TreeElement, exec: () => T) => T; 210 | getCurrentRenderingChildInstance: () => TreeElement<'CHILD'>; 211 | getCurrentHook: () => HooksData | null; 212 | getCurrentHookIndex: () => number; 213 | setCurrentHook: (hook: HooksData) => void; 214 | }; 215 | NULL: {}; 216 | PROVIDER: { 217 | element: ElementProvider; 218 | children: TreeElement; 219 | }; 220 | CHILD: { 221 | snapshot: TreeElementSnapshot<'CHILD'> | undefined; 222 | element: ElementComponent; 223 | hooks: Array | null; 224 | nextHooks: Array; 225 | // is set to true when the component or one of it's children has a new state 226 | // and thus need to be rendered even if props are equal 227 | dirty: boolean; 228 | }; 229 | OBJECT: { children: { [key: string]: TreeElement } }; 230 | ARRAY: { children: Array }; 231 | MAP: { 232 | children: Map; 233 | }; 234 | }; 235 | 236 | export type TreeElementType = keyof TreeElementData; 237 | 238 | type TreeElementResolved = { 239 | [K in keyof TreeElementData]: TreeElementCommon & { 240 | type: K; 241 | } & TreeElementData[K]; 242 | }; 243 | 244 | export type TreeElement = TreeElementResolved[K]; 245 | 246 | type CreateTreeElementMap = T; 247 | 248 | export type TreeElementRaw = CreateTreeElementMap<{ 249 | ROOT: DemocratRootElement; 250 | NULL: null; 251 | CHILD: ElementComponent; 252 | PROVIDER: ElementProvider; 253 | ARRAY: Array; 254 | OBJECT: { [key: string]: any }; 255 | MAP: Map; 256 | }>; 257 | 258 | type TreeElementPathData = CreateTreeElementMap<{ 259 | ROOT: {}; 260 | NULL: {}; 261 | CHILD: { 262 | hookIndex: number; 263 | }; 264 | PROVIDER: {}; 265 | ARRAY: { index: number }; 266 | OBJECT: { objectKey: string }; 267 | MAP: { mapKey: any }; 268 | }>; 269 | 270 | type TreeElementPathResolved = { 271 | [K in keyof TreeElementData]: { 272 | type: K; 273 | } & TreeElementPathData[K]; 274 | }; 275 | 276 | export type TreeElementPath = TreeElementPathResolved[K]; 277 | 278 | export type HookSnapshot = 279 | | { type: 'CHILDREN'; child: TreeElementSnapshot } 280 | | { type: 'STATE'; value: any } 281 | | { type: 'REDUCER'; value: any } 282 | | null; 283 | 284 | type TreeElementSnapshotData = CreateTreeElementMap<{ 285 | ROOT: { 286 | children: TreeElementSnapshot; 287 | }; 288 | NULL: {}; 289 | CHILD: { 290 | hooks: Array; 291 | }; 292 | PROVIDER: { 293 | children: TreeElementSnapshot; 294 | }; 295 | ARRAY: { children: Array }; 296 | OBJECT: { children: { [key: string]: TreeElementSnapshot } }; 297 | MAP: { children: Map }; 298 | }>; 299 | 300 | type TreeElementSnapshotResolved = { 301 | [K in keyof TreeElementData]: { 302 | type: K; 303 | } & TreeElementSnapshotData[K]; 304 | }; 305 | 306 | export type TreeElementSnapshot = TreeElementSnapshotResolved[K]; 307 | 308 | export type Snapshot = TreeElementSnapshot<'ROOT'>; 309 | 310 | export type ReducerWithoutAction = (prevState: S) => S; 311 | export type ReducerStateWithoutAction> = 312 | R extends ReducerWithoutAction ? S : never; 313 | export type DispatchWithoutAction = () => void; 314 | export type Reducer = (prevState: S, action: A) => S; 315 | export type ReducerState> = R extends Reducer ? S : never; 316 | export type ReducerAction> = R extends Reducer ? A : never; 317 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { DEMOCRAT_CONTEXT, DEMOCRAT_ELEMENT, DEMOCRAT_ROOT } from './symbols'; 2 | import type { 3 | AnyFn, 4 | Context, 5 | ContextProviderProps, 6 | DemocratContextProvider, 7 | DemocratRootElement, 8 | DependencyList, 9 | Element, 10 | ElementComponent, 11 | ElementProvider, 12 | FunctionComponent, 13 | HooksData, 14 | OnIdle, 15 | Patch, 16 | Patches, 17 | ResolveType, 18 | TreeElement, 19 | TreeElementCommon, 20 | TreeElementData, 21 | TreeElementPath, 22 | TreeElementType, 23 | } from './types'; 24 | 25 | export function isValidElement(maybe: unknown): maybe is Element { 26 | return Boolean(maybe && (maybe as any)[DEMOCRAT_ELEMENT] === true); 27 | } 28 | 29 | export function isRootElement(maybe: unknown): maybe is DemocratRootElement { 30 | return Boolean(maybe && (maybe as any)[DEMOCRAT_ELEMENT] === true && (maybe as any)[DEMOCRAT_ROOT] === true); 31 | } 32 | 33 | /** 34 | * Create a Democrat element 35 | * This function is not strictly typed, 36 | * To safely create element use createFactory(component).createElement 37 | */ 38 | export function createElement( 39 | component: FunctionComponent, 40 | props: P, 41 | key?: string | number | undefined, 42 | ): Element; 43 | export function createElement( 44 | context: DemocratContextProvider

, 45 | props: ContextProviderProps, 46 | key?: string | number | undefined, 47 | ): Element>; 48 | export function createElement( 49 | component: FunctionComponent | DemocratContextProvider

, 50 | props: P = {} as any, 51 | key?: string | number | undefined, 52 | ): Element { 53 | const element: Element = { 54 | [DEMOCRAT_ELEMENT]: true, 55 | type: component as any, 56 | props: props as any, 57 | key, 58 | }; 59 | return element as any; 60 | } 61 | 62 | export function objectShallowEqual( 63 | deps1: { [key: string]: any } | undefined, 64 | deps2: { [key: string]: any } | undefined, 65 | ): boolean { 66 | if (deps1 === deps2) { 67 | return true; 68 | } 69 | if (deps1 === undefined || deps2 === undefined) { 70 | return false; 71 | } 72 | const keys = Object.keys(deps1); 73 | if (!arrayShallowEqual(keys, Object.keys(deps2))) { 74 | return false; 75 | } 76 | for (let i = 0; i < keys.length; i++) { 77 | const key = keys[i]; 78 | if (!Object.is(deps1[key], deps2[key])) { 79 | return false; 80 | } 81 | } 82 | return true; 83 | } 84 | 85 | export function sameObjectKeys(obj1: { [key: string]: any }, obj2: { [key: string]: any }): boolean { 86 | return arrayShallowEqual(Object.keys(obj1).sort(), Object.keys(obj2).sort()); 87 | } 88 | 89 | export function depsChanged(deps1: DependencyList | undefined, deps2: DependencyList | undefined): boolean { 90 | if (deps1 === undefined || deps2 === undefined) { 91 | return true; 92 | } 93 | if (deps1.length !== deps2.length) { 94 | return true; 95 | } 96 | return !arrayShallowEqual(deps1, deps2); 97 | } 98 | 99 | export function mapObject( 100 | obj: T, 101 | mapper: (v: T[keyof T], key: string) => U, 102 | ): { [K in keyof T]: U } { 103 | return Object.keys(obj).reduce( 104 | (acc, key) => { 105 | (acc as any)[key] = mapper(obj[key], key); 106 | return acc; 107 | }, 108 | {} as { [K in keyof T]: U }, 109 | ); 110 | } 111 | 112 | export function mapMap(source: Map, mapper: (v: V, k: K) => U): Map { 113 | const result = new Map(); 114 | source.forEach((v, k) => { 115 | result.set(k, mapper(v, k)); 116 | }); 117 | return result; 118 | } 119 | 120 | export function createContext(): Context; 121 | export function createContext(defaultValue: T): Context; 122 | export function createContext(defaultValue?: T): Context { 123 | const Provider: DemocratContextProvider = { 124 | [DEMOCRAT_CONTEXT]: 'PROVIDER', 125 | context: null as any, 126 | createElement: (props, key) => createElement(Provider, props as any, key) as any, 127 | }; 128 | const context: Context = { 129 | [DEMOCRAT_CONTEXT]: { 130 | hasDefault: defaultValue !== undefined && arguments.length === 1, 131 | defaultValue: defaultValue as any, // force undefined when there a no default value 132 | }, 133 | Provider, 134 | }; 135 | context.Provider.context = context; 136 | return context; 137 | } 138 | 139 | export function isComponentElement(element: Element): element is ElementComponent { 140 | return typeof element.type === 'function'; 141 | } 142 | 143 | export function isProviderElement(element: Element): element is ElementProvider { 144 | return typeof element.type !== 'function' && element.type[DEMOCRAT_CONTEXT] === 'PROVIDER'; 145 | } 146 | 147 | const nextId = (() => { 148 | let id = 0; 149 | return () => id++; 150 | })(); 151 | 152 | export function createTreeElement( 153 | type: T, 154 | parent: TreeElement, 155 | path: TreeElementPath, 156 | data: Omit & TreeElementData[T], 157 | ): TreeElement { 158 | const id = nextId(); 159 | return { 160 | type, 161 | id, 162 | state: 'created', 163 | path, 164 | parent, 165 | root: parent.type === 'ROOT' ? parent : parent.root, 166 | ...data, 167 | } as any; 168 | } 169 | 170 | export function createRootTreeElement(data: { 171 | onIdle: OnIdle; 172 | passiveMode: boolean; 173 | requestRender: (patch: Patch | null) => void; 174 | applyPatches: (patches: Patches) => void; 175 | }): TreeElement<'ROOT'> { 176 | let reactHooksSupported: boolean = false; 177 | const renderingStack: Array = []; 178 | 179 | const rootTreeElement: TreeElement<'ROOT'> = { 180 | type: 'ROOT', 181 | id: nextId(), 182 | mounted: false, 183 | state: 'created', 184 | root: null as any, 185 | path: null as any, 186 | parent: null as any, 187 | value: null, 188 | previous: null, 189 | children: null as any, 190 | context: new Map(), 191 | supportReactHooks, 192 | findProvider, 193 | isRendering, 194 | markDirty, 195 | withGlobalRenderingInstance, 196 | getCurrentRenderingChildInstance, 197 | getCurrentHook, 198 | getCurrentHookIndex, 199 | setCurrentHook, 200 | ...data, 201 | }; 202 | 203 | rootTreeElement.root = rootTreeElement; 204 | 205 | return rootTreeElement; 206 | 207 | function markDirty(instance: TreeElement<'CHILD'>, limit: TreeElement | null = null) { 208 | let current: TreeElement | null = instance; 209 | while (current !== null && current !== limit) { 210 | if (current.type === 'CHILD') { 211 | if (current.dirty === true) { 212 | break; 213 | } 214 | current.dirty = true; 215 | } 216 | current = current.parent; 217 | } 218 | } 219 | 220 | function findProvider(context: Context): TreeElement<'PROVIDER'> | null { 221 | for (let i = renderingStack.length - 1; i >= 0; i--) { 222 | const instance = renderingStack[i]; 223 | if (instance.type === 'PROVIDER' && instance.element.type.context === context) { 224 | return instance; 225 | } 226 | } 227 | return null; 228 | } 229 | 230 | function isRendering(): boolean { 231 | return renderingStack.length > 0; 232 | } 233 | 234 | function supportReactHooks(ReactInstance: any, hooks: any) { 235 | if (reactHooksSupported === false) { 236 | reactHooksSupported = true; 237 | const methods = ['useState', 'useReducer', 'useEffect', 'useMemo', 'useCallback', 'useLayoutEffect', 'useRef']; 238 | methods.forEach((name) => { 239 | const originalFn = ReactInstance[name] as AnyFn; 240 | ReactInstance[name] = (...args: Array) => { 241 | if (isRendering()) { 242 | return (hooks[name] as AnyFn)(...args); 243 | } 244 | return originalFn(...args); 245 | }; 246 | }); 247 | } 248 | } 249 | 250 | function withGlobalRenderingInstance(current: TreeElement, exec: () => T): T { 251 | renderingStack.push(current); 252 | const result = exec(); 253 | renderingStack.pop(); 254 | return result; 255 | } 256 | 257 | function getCurrentRenderingChildInstance(): TreeElement<'CHILD'> { 258 | if (renderingStack.length === 0) { 259 | throw new Error(`Hooks used outside of render !`); 260 | } 261 | const currentInstance = renderingStack[renderingStack.length - 1]; 262 | if (currentInstance.type !== 'CHILD') { 263 | throw new Error(`Current rendering instance is not of type CHILD`); 264 | } 265 | return currentInstance; 266 | } 267 | 268 | function getCurrentHook(): HooksData | null { 269 | const instance = getCurrentRenderingChildInstance(); 270 | if (instance.hooks && instance.hooks.length > 0) { 271 | return instance.hooks[instance.nextHooks.length] || null; 272 | } 273 | return null; 274 | } 275 | 276 | function getCurrentHookIndex(): number { 277 | const instance = getCurrentRenderingChildInstance(); 278 | if (instance.nextHooks) { 279 | return instance.nextHooks.length; 280 | } 281 | return 0; 282 | } 283 | 284 | function setCurrentHook(hook: HooksData) { 285 | const instance = getCurrentRenderingChildInstance(); 286 | instance.nextHooks.push(hook); 287 | } 288 | } 289 | 290 | /** 291 | * Array have the same structure if 292 | * - they have the same length 293 | * - the keys have not moved 294 | */ 295 | export function sameArrayStructure(prev: Array, children: Array): boolean { 296 | if (prev.length !== children.length) { 297 | return false; 298 | } 299 | const prevKeys = prev.map((item) => (item.type === 'CHILD' ? item.element.key : undefined)); 300 | const childrenKeys = children.map((item) => (isValidElement(item) ? item.key : undefined)); 301 | return arrayShallowEqual(prevKeys, childrenKeys); 302 | } 303 | 304 | export function sameMapStructure(prev: Map, children: Map): boolean { 305 | if (prev.size !== children.size) { 306 | return false; 307 | } 308 | let allIn = true; 309 | prev.forEach((_v, k) => { 310 | if (allIn === true && children.has(k) === false) { 311 | allIn = false; 312 | } 313 | }); 314 | return allIn; 315 | } 316 | 317 | export function registerContextSub(instance: TreeElement<'CHILD'>, context: Context): void { 318 | const root = instance.root; 319 | if (!root.context.has(context)) { 320 | root.context.set(context, new Set()); 321 | } 322 | root.context.get(context)!.add(instance); 323 | } 324 | 325 | export function unregisterContextSub(instance: TreeElement<'CHILD'>, context: Context): void { 326 | const root = instance.root; 327 | if (!root.context.has(context)) { 328 | return; 329 | } 330 | const ctx = root.context.get(context)!; 331 | ctx.delete(instance); 332 | if (ctx.size === 0) { 333 | root.context.delete(context); 334 | } 335 | } 336 | 337 | export function markContextSubDirty(instance: TreeElement, context: Context): void { 338 | const root = instance.root; 339 | const ctx = root.context.get(context); 340 | if (ctx) { 341 | ctx.forEach((c) => { 342 | if (isDescendantOf(c, instance)) { 343 | root.markDirty(c, instance); 344 | } 345 | }); 346 | } 347 | } 348 | 349 | export function isElementInstance(instance: TreeElement): instance is TreeElement<'CHILD' | 'PROVIDER'> { 350 | if (instance.type === 'CHILD' || instance.type === 'PROVIDER') { 351 | return true; 352 | } 353 | return false; 354 | } 355 | 356 | export function getInstanceKey(instance: TreeElement): string | number | undefined { 357 | return isElementInstance(instance) ? instance.element.key : undefined; 358 | } 359 | 360 | export function getPatchPath(instance: TreeElement): Array { 361 | const path: Array = []; 362 | let current: TreeElement | null = instance; 363 | while (current !== null && current.type !== 'ROOT') { 364 | path.unshift(current.path); 365 | if (current.parent === undefined) { 366 | console.log(current); 367 | } 368 | current = current.parent; 369 | } 370 | return path; 371 | } 372 | 373 | // eslint-disable-next-line @typescript-eslint/ban-types 374 | export function isPlainObject(o: unknown): o is object { 375 | if (isObjectObject(o) === false) return false; 376 | 377 | // If has modified constructor 378 | const ctor = (o as any).constructor; 379 | if (typeof ctor !== 'function') return false; 380 | 381 | // If has modified prototype 382 | const prot = ctor.prototype; 383 | if (isObjectObject(prot) === false) return false; 384 | 385 | // If constructor does not have an Object-specific method 386 | if (Object.prototype.hasOwnProperty.call(prot, 'isPrototypeOf') === false) { 387 | return false; 388 | } 389 | 390 | // Most likely a plain Object 391 | return true; 392 | } 393 | 394 | function isObject(val: any) { 395 | return val != null && typeof val === 'object' && Array.isArray(val) === false; 396 | } 397 | 398 | function isObjectObject(o: any) { 399 | return isObject(o) === true && Object.prototype.toString.call(o) === '[object Object]'; 400 | } 401 | 402 | function arrayShallowEqual(deps1: ReadonlyArray, deps2: ReadonlyArray): boolean { 403 | if (deps1 === deps2) { 404 | return true; 405 | } 406 | if (deps1.length !== deps2.length) { 407 | return false; 408 | } 409 | for (let i = 0; i < deps1.length; i++) { 410 | const dep = deps1[i]; 411 | if (!Object.is(dep, deps2[i])) { 412 | return false; 413 | } 414 | } 415 | return true; 416 | } 417 | 418 | function isDescendantOf(instance: TreeElement, parent: TreeElement) { 419 | let current: TreeElement | null = instance; 420 | while (current !== null && current !== parent) { 421 | current = current.parent; 422 | } 423 | return current === parent; 424 | } 425 | 426 | export type Timer = ReturnType; 427 | -------------------------------------------------------------------------------- /tests/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, test, vi } from 'vitest'; 2 | import * as Democrat from '../src/mod'; 3 | import { mapMap, removeFunctionsDeep, waitForNextState, waitForNextTick } from './utils'; 4 | 5 | test('basic count state', async () => { 6 | const onRender = vi.fn(); 7 | const Counter = Democrat.createFactory(() => { 8 | onRender(); 9 | const [count, setCount] = Democrat.useState(0); 10 | return { 11 | count, 12 | setCount, 13 | }; 14 | }); 15 | const store = Democrat.createStore(Counter.createElement()); 16 | expect(store.getState().count).toEqual(0); 17 | await waitForNextTick(); 18 | store.getState().setCount(42); 19 | await waitForNextState(store); 20 | expect(store.getState().count).toEqual(42); 21 | expect(onRender).toHaveBeenCalledTimes(2); 22 | }); 23 | 24 | test('Destroy just after create', async () => { 25 | const effect = vi.fn(); 26 | const Counter = Democrat.createFactory(() => { 27 | Democrat.useEffect(effect, []); 28 | return null; 29 | }); 30 | const store = Democrat.createStore(Counter.createElement()); 31 | store.destroy(); 32 | expect(effect).not.toHaveBeenCalled(); 33 | await waitForNextTick(); 34 | expect(effect).not.toHaveBeenCalled(); 35 | }); 36 | 37 | test('generic component', () => { 38 | const Counter = Democrat.createGenericFactory(function (props: { val: R }): R { 39 | return props.val; 40 | }); 41 | const store = Democrat.createStore(Counter.createElement((c) => c({ val: 42 }))); 42 | expect(store.getState()).toEqual(42); 43 | }); 44 | 45 | test('subscribe', async () => { 46 | const onRender = vi.fn(); 47 | const Counter = Democrat.createFactory(() => { 48 | onRender(); 49 | const [count, setCount] = Democrat.useState(0); 50 | 51 | return { 52 | count, 53 | setCount, 54 | }; 55 | }); 56 | const store = Democrat.createStore(Counter.createElement()); 57 | const onState = vi.fn(); 58 | store.subscribe(onState); 59 | store.getState().setCount(42); 60 | await waitForNextState(store); 61 | store.getState().setCount(0); 62 | await waitForNextState(store); 63 | expect(onState).toHaveBeenCalledTimes(2); 64 | }); 65 | 66 | test('useReducer', async () => { 67 | type State = { count: number }; 68 | type Action = { type: 'increment' } | { type: 'decrement' }; 69 | 70 | const initialState: State = { count: 0 }; 71 | 72 | function reducer(state: State, action: Action): State { 73 | switch (action.type) { 74 | case 'increment': 75 | return { count: state.count + 1 }; 76 | case 'decrement': 77 | return { count: state.count - 1 }; 78 | default: 79 | return state; 80 | } 81 | } 82 | const onRender = vi.fn(); 83 | const Counter = Democrat.createFactory(() => { 84 | onRender(); 85 | const [count, dispatch] = Democrat.useReducer(reducer, initialState); 86 | 87 | return { 88 | count, 89 | dispatch, 90 | }; 91 | }); 92 | const store = Democrat.createStore(Counter.createElement()); 93 | const onState = vi.fn(); 94 | store.subscribe(onState); 95 | store.getState().dispatch({ type: 'increment' }); 96 | await waitForNextState(store); 97 | expect(store.getState().count).toEqual({ count: 1 }); 98 | store.getState().dispatch({ type: 'decrement' }); 99 | await waitForNextState(store); 100 | expect(store.getState().count).toEqual({ count: 0 }); 101 | const prevState = store.getState().count; 102 | expect(onState).toHaveBeenCalledTimes(2); 103 | store.getState().dispatch({} as any); 104 | expect(store.getState().count).toBe(prevState); 105 | }); 106 | 107 | test('subscribe wit useMemo', async () => { 108 | const onRender = vi.fn(); 109 | const Counter = () => { 110 | onRender(); 111 | const [count, setCount] = Democrat.useState(0); 112 | 113 | const result = Democrat.useMemo( 114 | () => ({ 115 | count, 116 | setCount, 117 | }), 118 | [count, setCount], 119 | ); 120 | 121 | return result; 122 | }; 123 | const store = Democrat.createStore(Democrat.createElement(Counter, {})); 124 | const onState = vi.fn(); 125 | store.subscribe(onState); 126 | store.getState().setCount(42); 127 | await waitForNextState(store); 128 | store.getState().setCount(0); 129 | await waitForNextState(store); 130 | expect(onState).toHaveBeenCalledTimes(2); 131 | }); 132 | 133 | test('set two states', async () => { 134 | expect.assertions(3); 135 | const render = vi.fn(); 136 | const Counter = () => { 137 | render(); 138 | const [countA, setCountA] = Democrat.useState(0); 139 | const [countB, setCountB] = Democrat.useState(0); 140 | const setCount = Democrat.useCallback((v: number) => { 141 | setCountA(v); 142 | setCountB(v); 143 | }, []); 144 | 145 | return { 146 | count: countA + countB, 147 | setCount, 148 | }; 149 | }; 150 | const store = Democrat.createStore(Democrat.createElement(Counter, {})); 151 | expect(store.getState().count).toEqual(0); 152 | store.getState().setCount(1); 153 | await waitForNextState(store); 154 | expect(render).toHaveBeenCalledTimes(2); 155 | expect(store.getState().count).toEqual(2); 156 | }); 157 | 158 | test('effects runs', async () => { 159 | const onLayoutEffect = vi.fn(); 160 | const onEffect = vi.fn(); 161 | 162 | const Counter = () => { 163 | const [count, setCount] = Democrat.useState(0); 164 | 165 | Democrat.useLayoutEffect(() => { 166 | onLayoutEffect(); 167 | }, [count]); 168 | 169 | Democrat.useEffect(() => { 170 | onEffect(); 171 | }, [count]); 172 | 173 | return { 174 | count, 175 | setCount, 176 | }; 177 | }; 178 | Democrat.createStore(Democrat.createElement(Counter, {})); 179 | await waitForNextTick(); 180 | expect(onLayoutEffect).toHaveBeenCalled(); 181 | expect(onEffect).toHaveBeenCalled(); 182 | }); 183 | 184 | test('effects cleanup runs', async () => { 185 | const onLayoutEffect = vi.fn(); 186 | const onLayoutEffectCleanup = vi.fn(); 187 | const onEffect = vi.fn(); 188 | const onEffectCleanup = vi.fn(); 189 | 190 | const Counter = () => { 191 | const [count, setCount] = Democrat.useState(0); 192 | 193 | Democrat.useLayoutEffect(() => { 194 | onLayoutEffect(); 195 | return onLayoutEffectCleanup; 196 | }, [count]); 197 | 198 | Democrat.useEffect(() => { 199 | onEffect(); 200 | return onEffectCleanup; 201 | }, [count]); 202 | 203 | return { 204 | count, 205 | setCount, 206 | }; 207 | }; 208 | const store = Democrat.createStore(Democrat.createElement(Counter, {})); 209 | await waitForNextTick(); 210 | expect(onLayoutEffect).toBeCalledTimes(1); 211 | expect(onEffect).toBeCalledTimes(1); 212 | store.getState().setCount(42); 213 | await waitForNextState(store); 214 | await waitForNextTick(); 215 | expect(onLayoutEffect).toBeCalledTimes(2); 216 | expect(onEffect).toBeCalledTimes(2); 217 | expect(onLayoutEffectCleanup).toHaveBeenCalled(); 218 | expect(onEffectCleanup).toHaveBeenCalled(); 219 | }); 220 | 221 | test('runs cleanup only once', async () => { 222 | const onLayoutEffect = vi.fn(); 223 | const onLayoutEffectCleanup = vi.fn(); 224 | 225 | const Child = () => { 226 | Democrat.useLayoutEffect(() => { 227 | onLayoutEffect(); 228 | return onLayoutEffectCleanup; 229 | }, []); 230 | }; 231 | 232 | const Counter = () => { 233 | const [count, setCount] = Democrat.useState(0); 234 | 235 | const child = Democrat.useChildren(count < 10 ? Democrat.createElement(Child, {}) : null); 236 | 237 | return { 238 | child, 239 | count, 240 | setCount, 241 | }; 242 | }; 243 | const store = Democrat.createStore(Democrat.createElement(Counter, {})); 244 | await waitForNextTick(); 245 | expect(onLayoutEffectCleanup).not.toHaveBeenCalled(); 246 | expect(onLayoutEffect).toBeCalledTimes(1); 247 | // should unmount 248 | store.getState().setCount(12); 249 | await waitForNextState(store); 250 | expect(onLayoutEffectCleanup).toBeCalledTimes(1); 251 | expect(onLayoutEffect).toBeCalledTimes(1); 252 | // stay unmounted 253 | store.getState().setCount(13); 254 | await waitForNextState(store); 255 | expect(onLayoutEffectCleanup).toBeCalledTimes(1); 256 | expect(onLayoutEffect).toBeCalledTimes(1); 257 | }); 258 | 259 | test('use effect when re-render', async () => { 260 | const onUseEffect = vi.fn(); 261 | const Counter = () => { 262 | const [count, setCount] = Democrat.useState(0); 263 | 264 | Democrat.useEffect(() => { 265 | onUseEffect(); 266 | if (count === 0) { 267 | setCount(42); 268 | } 269 | }, [count]); 270 | 271 | return { 272 | count, 273 | setCount, 274 | }; 275 | }; 276 | 277 | const store = Democrat.createStore(Democrat.createElement(Counter, {})); 278 | await waitForNextState(store); 279 | expect(onUseEffect).toHaveBeenCalledTimes(1); 280 | expect(store.getState().count).toBe(42); 281 | await waitForNextTick(); 282 | expect(onUseEffect).toHaveBeenCalledTimes(2); 283 | }); 284 | 285 | test('multiple counters (array children)', async () => { 286 | const Counter = () => { 287 | const [count, setCount] = Democrat.useState(0); 288 | return { 289 | count, 290 | setCount, 291 | }; 292 | }; 293 | const Counters = () => { 294 | const [numberOfCounter, setNumberOfCounter] = Democrat.useState(3); 295 | 296 | const counters = Democrat.useChildren( 297 | new Array(numberOfCounter).fill(null).map(() => Democrat.createElement(Counter, {})), 298 | ); 299 | 300 | const addCounter = Democrat.useCallback(() => { 301 | setNumberOfCounter((v) => v + 1); 302 | }, []); 303 | 304 | return { 305 | counters, 306 | addCounter, 307 | }; 308 | }; 309 | 310 | const store = Democrat.createStore(Democrat.createElement(Counters, {})); 311 | expect(store.getState()).toMatchInlineSnapshot(` 312 | { 313 | "addCounter": [Function], 314 | "counters": [ 315 | { 316 | "count": 0, 317 | "setCount": [Function], 318 | }, 319 | { 320 | "count": 0, 321 | "setCount": [Function], 322 | }, 323 | { 324 | "count": 0, 325 | "setCount": [Function], 326 | }, 327 | ], 328 | } 329 | `); 330 | expect(store.getState().counters.length).toBe(3); 331 | expect(store.getState().counters[0].count).toBe(0); 332 | store.getState().counters[0].setCount(1); 333 | await waitForNextState(store); 334 | expect(store.getState().counters[0].count).toBe(1); 335 | store.getState().addCounter(); 336 | await waitForNextState(store); 337 | expect(store.getState().counters.length).toEqual(4); 338 | expect(store.getState().counters[3].count).toBe(0); 339 | }); 340 | 341 | test('multiple counters (object children)', () => { 342 | const Counter = ({ initialCount = 0 }: { initialCount?: number }) => { 343 | const [count, setCount] = Democrat.useState(initialCount); 344 | 345 | return { 346 | count, 347 | setCount, 348 | }; 349 | }; 350 | const Counters = () => { 351 | const counters = Democrat.useChildren({ 352 | counterA: Democrat.createElement(Counter, { initialCount: 2 }), 353 | counterB: Democrat.createElement(Counter, {}), 354 | }); 355 | 356 | const sum = counters.counterA.count + counters.counterB.count; 357 | 358 | return { counters, sum }; 359 | }; 360 | 361 | const store = Democrat.createStore(Democrat.createElement(Counters, {})); 362 | expect(store.getState()).toMatchInlineSnapshot(` 363 | { 364 | "counters": { 365 | "counterA": { 366 | "count": 2, 367 | "setCount": [Function], 368 | }, 369 | "counterB": { 370 | "count": 0, 371 | "setCount": [Function], 372 | }, 373 | }, 374 | "sum": 2, 375 | } 376 | `); 377 | }); 378 | 379 | test('multiple counters (object children update)', async () => { 380 | const Counter = ({ initialCount = 0 }: { initialCount?: number }) => { 381 | const [count, setCount] = Democrat.useState(initialCount); 382 | 383 | return { 384 | count, 385 | setCount, 386 | }; 387 | }; 388 | const Counters = () => { 389 | const [showCounterC, setShowCounterC] = Democrat.useState(false); 390 | 391 | const counters = Democrat.useChildren({ 392 | counterA: Democrat.createElement(Counter, { initialCount: 2 }), 393 | counterB: Democrat.createElement(Counter, {}), 394 | counterC: showCounterC ? Democrat.createElement(Counter, {}) : null, 395 | }); 396 | 397 | const sum = counters.counterA.count + counters.counterB.count; 398 | 399 | const toggle = Democrat.useCallback(() => setShowCounterC((prev) => !prev), []); 400 | 401 | return { counters, sum, toggle }; 402 | }; 403 | 404 | const store = Democrat.createStore(Democrat.createElement(Counters, {})); 405 | expect(store.getState()).toMatchInlineSnapshot(` 406 | { 407 | "counters": { 408 | "counterA": { 409 | "count": 2, 410 | "setCount": [Function], 411 | }, 412 | "counterB": { 413 | "count": 0, 414 | "setCount": [Function], 415 | }, 416 | "counterC": null, 417 | }, 418 | "sum": 2, 419 | "toggle": [Function], 420 | } 421 | `); 422 | store.getState().toggle(); 423 | await waitForNextState(store); 424 | expect(store.getState()).toMatchInlineSnapshot(` 425 | { 426 | "counters": { 427 | "counterA": { 428 | "count": 2, 429 | "setCount": [Function], 430 | }, 431 | "counterB": { 432 | "count": 0, 433 | "setCount": [Function], 434 | }, 435 | "counterC": { 436 | "count": 0, 437 | "setCount": [Function], 438 | }, 439 | }, 440 | "sum": 2, 441 | "toggle": [Function], 442 | } 443 | `); 444 | }); 445 | 446 | test('render a context', async () => { 447 | const NumCtx = Democrat.createContext(10); 448 | 449 | const Store = () => { 450 | const num = Democrat.useContext(NumCtx); 451 | const [count, setCount] = Democrat.useState(0); 452 | 453 | return { 454 | count: count + num, 455 | setCount, 456 | }; 457 | }; 458 | const store = Democrat.createStore( 459 | Democrat.createElement(NumCtx.Provider, { 460 | value: 42, 461 | children: Democrat.createElement(Store, {}), 462 | }), 463 | ); 464 | expect(store.getState().count).toEqual(42); 465 | store.getState().setCount(1); 466 | await waitForNextState(store); 467 | expect(store.getState().count).toEqual(43); 468 | }); 469 | 470 | test('render a context and update it', async () => { 471 | const NumCtx = Democrat.createContext(10); 472 | 473 | const Child = () => { 474 | const num = Democrat.useContext(NumCtx); 475 | const [count, setCount] = Democrat.useState(0); 476 | 477 | return { 478 | count: count + num, 479 | setCount, 480 | }; 481 | }; 482 | 483 | const Parent = () => { 484 | const [num, setNum] = Democrat.useState(0); 485 | 486 | const { count, setCount } = Democrat.useChildren( 487 | Democrat.createElement(NumCtx.Provider, { 488 | value: num, 489 | children: Democrat.createElement(Child, {}), 490 | }), 491 | ); 492 | 493 | return { 494 | count, 495 | setCount, 496 | setNum, 497 | }; 498 | }; 499 | 500 | const store = Democrat.createStore(Democrat.createElement(Parent, {})); 501 | expect(store.getState().count).toEqual(0); 502 | store.getState().setCount(1); 503 | await waitForNextState(store); 504 | expect(store.getState().count).toEqual(1); 505 | store.getState().setNum(1); 506 | await waitForNextState(store); 507 | expect(store.getState().count).toEqual(2); 508 | }); 509 | 510 | test('read a context with no provider', () => { 511 | const NumCtx = Democrat.createContext(10); 512 | 513 | const Store = () => { 514 | const num = Democrat.useContext(NumCtx); 515 | const [count, setCount] = Democrat.useState(0); 516 | 517 | return { 518 | count: count + num, 519 | setCount, 520 | }; 521 | }; 522 | const store = Democrat.createStore(Democrat.createElement(Store, {})); 523 | expect(store.getState().count).toEqual(10); 524 | }); 525 | 526 | test('conditionnaly use a children', async () => { 527 | const Child = () => { 528 | return 42; 529 | }; 530 | 531 | const Store = () => { 532 | const [show, setShow] = Democrat.useState(false); 533 | 534 | const child = Democrat.useChildren(show ? Democrat.createElement(Child, {}) : null); 535 | 536 | return Democrat.useMemo( 537 | () => ({ 538 | setShow, 539 | child, 540 | }), 541 | [setShow, child], 542 | ); 543 | }; 544 | const store = Democrat.createStore(Democrat.createElement(Store, {})); 545 | expect(store.getState().child).toEqual(null); 546 | store.getState().setShow(true); 547 | await waitForNextState(store); 548 | expect(store.getState().child).toEqual(42); 549 | }); 550 | 551 | test('render a children', async () => { 552 | const Child = () => { 553 | const [count, setCount] = Democrat.useState(0); 554 | return Democrat.useMemo( 555 | () => ({ 556 | count, 557 | setCount, 558 | }), 559 | [count, setCount], 560 | ); 561 | }; 562 | 563 | const Store = () => { 564 | const [count, setCount] = Democrat.useState(0); 565 | const child = Democrat.useChildren(Democrat.createElement(Child, {})); 566 | return Democrat.useMemo( 567 | () => ({ 568 | count, 569 | setCount, 570 | child, 571 | }), 572 | [count, setCount, child], 573 | ); 574 | }; 575 | const store = Democrat.createStore(Democrat.createElement(Store, {})); 576 | expect(store.getState().child.count).toEqual(0); 577 | store.getState().child.setCount(42); 578 | await waitForNextState(store); 579 | expect(store.getState().child.count).toEqual(42); 580 | }); 581 | 582 | test('subscribe when children change', async () => { 583 | const Child = () => { 584 | const [count, setCount] = Democrat.useState(0); 585 | return Democrat.useMemo( 586 | () => ({ 587 | count, 588 | setCount, 589 | }), 590 | [count, setCount], 591 | ); 592 | }; 593 | 594 | const Store = () => { 595 | const [count, setCount] = Democrat.useState(0); 596 | const child = Democrat.useChildren(Democrat.createElement(Child, {})); 597 | return Democrat.useMemo( 598 | () => ({ 599 | count, 600 | setCount, 601 | child, 602 | }), 603 | [count, setCount, child], 604 | ); 605 | }; 606 | const store = Democrat.createStore(Democrat.createElement(Store, {})); 607 | const onState = vi.fn(); 608 | store.subscribe(onState); 609 | store.getState().child.setCount(42); 610 | await waitForNextState(store); 611 | store.getState().child.setCount(1); 612 | await waitForNextState(store); 613 | expect(store.getState().child.count).toEqual(1); 614 | expect(onState).toHaveBeenCalledTimes(2); 615 | }); 616 | 617 | test('useLayoutEffect', async () => { 618 | const Store = () => { 619 | const [count, setCount] = Democrat.useState(0); 620 | 621 | Democrat.useLayoutEffect(() => { 622 | if (count !== 0) { 623 | setCount(0); 624 | } 625 | }, [count]); 626 | 627 | return Democrat.useMemo( 628 | () => ({ 629 | count, 630 | setCount, 631 | }), 632 | [count, setCount], 633 | ); 634 | }; 635 | const store = Democrat.createStore(Democrat.createElement(Store, {})); 636 | const onState = vi.fn(); 637 | store.subscribe(onState); 638 | store.getState().setCount(42); 639 | await waitForNextState(store); 640 | expect(store.getState().count).toEqual(0); 641 | expect(onState).toHaveBeenCalledTimes(1); 642 | }); 643 | 644 | test('useEffect in loop', async () => { 645 | const Store = () => { 646 | const [count, setCount] = Democrat.useState(0); 647 | 648 | Democrat.useEffect(() => { 649 | if (count !== 0) { 650 | setCount(count - 1); 651 | } 652 | }, [count]); 653 | 654 | return Democrat.useMemo( 655 | () => ({ 656 | count, 657 | setCount, 658 | }), 659 | [count, setCount], 660 | ); 661 | }; 662 | const store = Democrat.createStore(Democrat.createElement(Store, {})); 663 | const onState = vi.fn(); 664 | store.subscribe(() => { 665 | onState(store.getState().count); 666 | }); 667 | store.getState().setCount(3); 668 | await waitForNextState(store); 669 | await waitForNextState(store); 670 | await waitForNextState(store); 671 | await waitForNextState(store); 672 | expect(store.getState().count).toEqual(0); 673 | expect(onState).toHaveBeenCalledTimes(4); 674 | expect(onState.mock.calls).toEqual([[3], [2], [1], [0]]); 675 | }); 676 | 677 | test('useLayoutEffect in loop', async () => { 678 | const Store = () => { 679 | const [count, setCount] = Democrat.useState(0); 680 | 681 | Democrat.useLayoutEffect(() => { 682 | if (count !== 0) { 683 | setCount(count - 1); 684 | } 685 | }, [count]); 686 | 687 | return Democrat.useMemo( 688 | () => ({ 689 | count, 690 | setCount, 691 | }), 692 | [count, setCount], 693 | ); 694 | }; 695 | const store = Democrat.createStore(Democrat.createElement(Store, {})); 696 | const onState = vi.fn(); 697 | store.subscribe(onState); 698 | store.getState().setCount(3); 699 | await waitForNextState(store); 700 | expect(store.getState().count).toEqual(0); 701 | expect(onState).toHaveBeenCalledTimes(1); 702 | }); 703 | 704 | test('useLayoutEffect & useEffect in loop (should run useEffect sync)', async () => { 705 | const Store = () => { 706 | const [count, setCount] = Democrat.useState(0); 707 | 708 | Democrat.useLayoutEffect(() => { 709 | if (count !== 0) { 710 | setCount(count - 1); 711 | } 712 | }, [count]); 713 | 714 | Democrat.useEffect(() => { 715 | if (count !== 0) { 716 | setCount(count - 1); 717 | } 718 | }, [count]); 719 | 720 | return Democrat.useMemo( 721 | () => ({ 722 | count, 723 | setCount, 724 | }), 725 | [count, setCount], 726 | ); 727 | }; 728 | const store = Democrat.createStore(Democrat.createElement(Store, {})); 729 | const onState = vi.fn(); 730 | store.subscribe(onState); 731 | store.getState().setCount(3); 732 | await waitForNextState(store); 733 | expect(store.getState().count).toEqual(0); 734 | expect(onState).toHaveBeenCalledTimes(1); 735 | }); 736 | 737 | test('array of children', async () => { 738 | const Child = ({ val }: { val: number }) => { 739 | return val * 2; 740 | }; 741 | 742 | const Store = () => { 743 | const [items, setItems] = Democrat.useState([23, 5, 7]); 744 | 745 | const addItem = Democrat.useCallback((item: number) => { 746 | setItems((prev) => [...prev, item]); 747 | }, []); 748 | 749 | const child = Democrat.useChildren(items.map((v) => Democrat.createElement(Child, { val: v }))); 750 | 751 | return Democrat.useMemo( 752 | () => ({ 753 | addItem, 754 | child, 755 | }), 756 | [addItem, child], 757 | ); 758 | }; 759 | const store = Democrat.createStore(Democrat.createElement(Store, {})); 760 | expect(store.getState().child).toEqual([46, 10, 14]); 761 | store.getState().addItem(6); 762 | await waitForNextState(store); 763 | expect(store.getState().child).toEqual([46, 10, 14, 12]); 764 | }); 765 | 766 | test('array of children with keys', async () => { 767 | const Child = ({ val }: { val: number }) => { 768 | return val * 2; 769 | }; 770 | 771 | const Store = () => { 772 | const [items, setItems] = Democrat.useState([23, 5, 7]); 773 | 774 | const addItem = Democrat.useCallback((item: number) => { 775 | setItems((prev) => [item, ...prev]); 776 | }, []); 777 | 778 | const child = Democrat.useChildren(items.map((v) => Democrat.createElement(Child, { val: v }, v))); 779 | 780 | return Democrat.useMemo( 781 | () => ({ 782 | addItem, 783 | child, 784 | }), 785 | [addItem, child], 786 | ); 787 | }; 788 | const store = Democrat.createStore(Democrat.createElement(Store, {})); 789 | expect(store.getState().child).toEqual([46, 10, 14]); 790 | store.getState().addItem(6); 791 | await waitForNextState(store); 792 | expect(store.getState().child).toEqual([12, 46, 10, 14]); 793 | }); 794 | 795 | test('remove key of array child', async () => { 796 | const onRender = vi.fn(); 797 | 798 | const Child = () => { 799 | return Math.random(); 800 | }; 801 | 802 | const Store = () => { 803 | onRender(); 804 | const [withKey, setWithKey] = Democrat.useState(true); 805 | 806 | const child = Democrat.useChildren([ 807 | withKey ? Democrat.createElement(Child, {}, 42) : Democrat.createElement(Child, {}), 808 | ]); 809 | 810 | return Democrat.useMemo( 811 | () => ({ 812 | setWithKey, 813 | child, 814 | }), 815 | [setWithKey, child], 816 | ); 817 | }; 818 | const store = Democrat.createStore(Democrat.createElement(Store, {})); 819 | const out1 = store.getState().child[0]; 820 | await waitForNextTick(); 821 | store.getState().setWithKey(false); 822 | await waitForNextState(store); 823 | expect(onRender).toHaveBeenCalledTimes(2); 824 | const out2 = store.getState().child[0]; 825 | expect(out2).not.toEqual(out1); 826 | }); 827 | 828 | test('render an array as root', () => { 829 | const Child = () => { 830 | return 42; 831 | }; 832 | expect(() => 833 | Democrat.createStore([Democrat.createElement(Child, {}), Democrat.createElement(Child, {})]), 834 | ).not.toThrow(); 835 | const store = Democrat.createStore([Democrat.createElement(Child, {}), Democrat.createElement(Child, {})]); 836 | expect(store.getState()).toEqual([42, 42]); 837 | }); 838 | 839 | test('throw when render invalid element', () => { 840 | expect(() => Democrat.createStore(new Date() as any)).toThrow('Invalid children type'); 841 | }); 842 | 843 | test('throw when render a Set', () => { 844 | expect(() => Democrat.createStore(new Set() as any)).toThrow('Set are not supported'); 845 | }); 846 | 847 | test('render a Map', () => { 848 | expect(() => Democrat.createStore(new Map())).not.toThrow(); 849 | }); 850 | 851 | test('update a Map', async () => { 852 | const Child = () => { 853 | const [count, setCount] = Democrat.useState(0); 854 | 855 | return Democrat.useMemo( 856 | () => ({ 857 | count, 858 | setCount, 859 | }), 860 | [count, setCount], 861 | ); 862 | }; 863 | 864 | const Store = () => { 865 | const [ids, setIds] = Democrat.useState>(new Map()); 866 | 867 | const children = Democrat.useChildren(mapMap(ids, () => Democrat.createElement(Child, {}))); 868 | 869 | const addChild = Democrat.useCallback((id: string) => { 870 | setIds((prev) => { 871 | const next = mapMap(prev, (v) => v); 872 | next.set(id, null); 873 | return next; 874 | }); 875 | }, []); 876 | 877 | const removeChild = Democrat.useCallback((id: string) => { 878 | setIds((prev) => { 879 | const next = mapMap(prev, (v) => v); 880 | next.delete(id); 881 | return next; 882 | }); 883 | }, []); 884 | 885 | return Democrat.useMemo( 886 | () => ({ 887 | children, 888 | removeChild, 889 | addChild, 890 | }), 891 | [children, removeChild, addChild], 892 | ); 893 | }; 894 | 895 | const store = Democrat.createStore(Democrat.createElement(Store, {})); 896 | expect(store.getState().children).toBeInstanceOf(Map); 897 | expect(store.getState().children.size).toBe(0); 898 | store.getState().addChild('a'); 899 | await waitForNextTick(); 900 | expect(store.getState().children.size).toBe(1); 901 | store.getState().addChild('b'); 902 | await waitForNextTick(); 903 | expect(store.getState().children.size).toBe(2); 904 | store.getState().removeChild('a'); 905 | await waitForNextTick(); 906 | expect(store.getState().children.size).toBe(1); 907 | store.getState().children.get('b')!.setCount(42); 908 | await waitForNextTick(); 909 | expect(store.getState().children.get('b')!.count).toBe(42); 910 | }); 911 | 912 | test('can save and restore unsing snapshot', async () => { 913 | const Child = () => { 914 | const [count, setCount] = Democrat.useState(0); 915 | 916 | return Democrat.useMemo( 917 | () => ({ 918 | count, 919 | setCount, 920 | }), 921 | [count, setCount], 922 | ); 923 | }; 924 | 925 | const Store = () => { 926 | const [ids, setIds] = Democrat.useState>(new Map()); 927 | 928 | const children = Democrat.useChildren(mapMap(ids, () => Democrat.createElement(Child, {}))); 929 | 930 | const addChild = Democrat.useCallback((id: string) => { 931 | setIds((prev) => { 932 | const next = mapMap(prev, (v) => v); 933 | next.set(id, null); 934 | return next; 935 | }); 936 | }, []); 937 | 938 | const removeChild = Democrat.useCallback((id: string) => { 939 | setIds((prev) => { 940 | const next = mapMap(prev, (v) => v); 941 | next.delete(id); 942 | return next; 943 | }); 944 | }, []); 945 | 946 | const sum = Democrat.useMemo(() => { 947 | return Array.from(children.values()).reduce((acc, item) => acc + item.count, 0); 948 | }, [children]); 949 | 950 | return Democrat.useMemo( 951 | () => ({ 952 | children, 953 | removeChild, 954 | addChild, 955 | sum, 956 | }), 957 | [children, removeChild, addChild], 958 | ); 959 | }; 960 | 961 | const store = Democrat.createStore(Democrat.createElement(Store, {})); 962 | store.getState().addChild('a'); 963 | await waitForNextTick(); 964 | store.getState().addChild('b'); 965 | await waitForNextTick(); 966 | store.getState().children.get('b')!.setCount(42); 967 | await waitForNextTick(); 968 | const finalState = removeFunctionsDeep(store.getState()); 969 | expect(finalState).toMatchInlineSnapshot(` 970 | { 971 | "addChild": "REMOVED_FUNCTION", 972 | "children": Map { 973 | "a" => { 974 | "count": 0, 975 | "setCount": "REMOVED_FUNCTION", 976 | }, 977 | "b" => { 978 | "count": 42, 979 | "setCount": "REMOVED_FUNCTION", 980 | }, 981 | }, 982 | "removeChild": "REMOVED_FUNCTION", 983 | "sum": 42, 984 | } 985 | `); 986 | const snapshot = store.getSnapshot(); 987 | const restoreStore = Democrat.createStore(Democrat.createElement(Store, {}), { snapshot }); 988 | expect(removeFunctionsDeep(restoreStore.getState())).toEqual(finalState); 989 | }); 990 | 991 | test('passing a React instance', () => { 992 | const useState = vi.fn((initialState: any) => [initialState]); 993 | const NotReact = { 994 | useState, 995 | }; 996 | 997 | const Store = () => { 998 | const [state] = NotReact.useState(42); 999 | return state; 1000 | }; 1001 | 1002 | Store(); 1003 | expect(useState).toHaveBeenCalledTimes(1); 1004 | 1005 | const store = Democrat.createStore(Democrat.createElement(Store, {}), { 1006 | ReactInstance: NotReact, 1007 | }); 1008 | expect(store.getState()).toEqual(42); 1009 | expect(useState).toHaveBeenCalledTimes(1); 1010 | Store(); 1011 | expect(useState).toHaveBeenCalledTimes(2); 1012 | }); 1013 | 1014 | test(`effects don't run in passive mode`, async () => { 1015 | const onEffect = vi.fn(); 1016 | 1017 | const Store = () => { 1018 | Democrat.useEffect(() => { 1019 | onEffect(); 1020 | }, []); 1021 | 1022 | return null; 1023 | }; 1024 | 1025 | Democrat.createStore(Democrat.createElement(Store, {})); 1026 | await waitForNextTick(); 1027 | expect(onEffect).toHaveBeenCalledTimes(1); 1028 | 1029 | Democrat.createStore(Democrat.createElement(Store, {}), { passiveMode: true }); 1030 | await waitForNextTick(); 1031 | expect(onEffect).toHaveBeenCalledTimes(1); // still one, effect not called 1032 | }); 1033 | 1034 | test('update root element with store.render', async () => { 1035 | const Store = ({ num }: { num: number }) => { 1036 | const [count, setCount] = Democrat.useState(0); 1037 | 1038 | return { 1039 | count: count + num, 1040 | setCount, 1041 | }; 1042 | }; 1043 | 1044 | const store = Democrat.createStore(Democrat.createElement(Store, { num: 3 })); 1045 | expect(store.getState().count).toBe(3); 1046 | store.getState().setCount(5); 1047 | await waitForNextState(store); 1048 | expect(store.getState().count).toBe(8); 1049 | store.render(Democrat.createElement(Store, { num: 10 })); 1050 | await waitForNextState(store); 1051 | expect(store.getState().count).toBe(15); 1052 | }); 1053 | 1054 | test('cannot set state on a destroyed store', async () => { 1055 | const Store = () => { 1056 | const [count, setCount] = Democrat.useState(42); 1057 | return { 1058 | count: count, 1059 | setCount, 1060 | }; 1061 | }; 1062 | const store = Democrat.createStore(Democrat.createElement(Store, {})); 1063 | expect(store.getState().count).toBe(42); 1064 | store.getState().setCount(8); 1065 | await waitForNextState(store); 1066 | expect(store.getState().count).toBe(8); 1067 | store.destroy(); 1068 | expect(() => store.getState().setCount(0)).toThrow('Store destroyed'); 1069 | }); 1070 | 1071 | test('cannot destroy a store twice', () => { 1072 | const Store = () => { 1073 | const [count, setCount] = Democrat.useState(42); 1074 | return { 1075 | count: count, 1076 | setCount, 1077 | }; 1078 | }; 1079 | const store = Democrat.createStore(Democrat.createElement(Store, {})); 1080 | store.destroy(); 1081 | expect(() => store.destroy()).toThrow('Store already destroyed'); 1082 | }); 1083 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Store } from '../src/mod'; 2 | 3 | export function waitForNextState(store: Store): Promise { 4 | return new Promise((res) => { 5 | const unsub = store.subscribe(() => { 6 | unsub(); 7 | res(store.getState()); 8 | }); 9 | }); 10 | } 11 | 12 | export function waitForNextTick(): Promise { 13 | return new Promise((res) => { 14 | setTimeout(() => { 15 | res(); 16 | }, 0); 17 | }); 18 | } 19 | 20 | export function mapMap(source: Map, mapper: (v: V, k: K) => U): Map { 21 | const result = new Map(); 22 | source.forEach((v, k) => { 23 | result.set(k, mapper(v, k)); 24 | }); 25 | return result; 26 | } 27 | 28 | export function mapObject( 29 | obj: T, 30 | mapper: (v: T[keyof T], key: string) => U, 31 | ): { [K in keyof T]: U } { 32 | return Object.keys(obj).reduce( 33 | (acc, key) => { 34 | (acc as any)[key] = mapper(obj[key], key); 35 | return acc; 36 | }, 37 | {} as { [K in keyof T]: U }, 38 | ); 39 | } 40 | 41 | export function removeFunctionsDeep(item: T): T { 42 | if (typeof item === 'function') { 43 | return 'REMOVED_FUNCTION' as any; 44 | } 45 | if (Array.isArray(item)) { 46 | return item.map((v) => removeFunctionsDeep(v)) as any; 47 | } 48 | if (isPlainObject(item)) { 49 | return mapObject(item, (v) => removeFunctionsDeep(v)) as any; 50 | } 51 | if (item instanceof Map) { 52 | return mapMap(item, (v) => removeFunctionsDeep(v)) as any; 53 | } 54 | return item; 55 | } 56 | 57 | // eslint-disable-next-line @typescript-eslint/ban-types 58 | export function isPlainObject(o: unknown): o is object { 59 | if (isObjectObject(o) === false) return false; 60 | 61 | // If has modified constructor 62 | const ctor = (o as any).constructor; 63 | if (typeof ctor !== 'function') return false; 64 | 65 | // If has modified prototype 66 | const prot = ctor.prototype; 67 | if (isObjectObject(prot) === false) return false; 68 | 69 | // If constructor does not have an Object-specific method 70 | if (Object.prototype.hasOwnProperty.call(prot, 'isPrototypeOf') === false) { 71 | return false; 72 | } 73 | 74 | // Most likely a plain Object 75 | return true; 76 | } 77 | 78 | function isObject(val: any) { 79 | return val != null && typeof val === 'object' && Array.isArray(val) === false; 80 | } 81 | 82 | function isObjectObject(o: any) { 83 | return isObject(o) === true && Object.prototype.toString.call(o) === '[object Object]'; 84 | } 85 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "include": ["src", "tests", "vitest.config.ts"], 4 | "compilerOptions": { 5 | "rootDir": ".", 6 | "outDir": "dist", 7 | "target": "ESNext", 8 | "module": "ES2020", 9 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 10 | "importHelpers": false, 11 | "moduleResolution": "node", 12 | "esModuleInterop": true, 13 | "jsx": "react-jsx", 14 | "isolatedModules": true, 15 | "declaration": true, 16 | "sourceMap": true, 17 | "noEmit": true, 18 | "types": [], 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noImplicitReturns": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "skipLibCheck": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | test: { environment: 'jsdom' }, 7 | }); 8 | --------------------------------------------------------------------------------