├── .npmrc ├── packages ├── compostate │ ├── pridepack.json │ ├── tsconfig.json │ ├── tsconfig.eslint.json │ ├── .eslintrc.cjs │ ├── src │ │ ├── reactivity │ │ │ ├── readonly.ts │ │ │ ├── trackable.ts │ │ │ ├── template.ts │ │ │ ├── types.ts │ │ │ ├── resource.ts │ │ │ ├── nodes │ │ │ │ ├── reactive-weak-keys.ts │ │ │ │ └── reactive-keys.ts │ │ │ ├── debounce.ts │ │ │ ├── reactive.ts │ │ │ ├── reactive-weak-set.ts │ │ │ ├── reactive-weak-map.ts │ │ │ ├── refs.ts │ │ │ ├── reactive-object.ts │ │ │ ├── reactive-set.ts │ │ │ ├── reactive-map.ts │ │ │ ├── array.ts │ │ │ └── core.ts │ │ ├── utils │ │ │ └── is-plain-object.ts │ │ ├── index.ts │ │ ├── linked-work.ts │ │ └── scheduler.ts │ ├── package.json │ ├── .gitignore │ ├── test │ │ └── index.test.ts │ └── README.md ├── compostate-element │ ├── pridepack.json │ ├── test │ │ └── index.test.ts │ ├── src │ │ ├── utils │ │ │ └── kebabify.ts │ │ ├── index.ts │ │ ├── renderer.ts │ │ ├── composition.ts │ │ └── define.ts │ ├── .eslintrc.cjs │ ├── tsconfig.json │ ├── tsconfig.eslint.json │ ├── package.json │ ├── .gitignore │ └── README.md ├── react-compostate │ ├── pridepack.json │ ├── src │ │ ├── defineComponent.tsx │ │ ├── index.ts │ │ ├── composition.ts │ │ └── useCompostateSetup.tsx │ ├── test │ │ ├── suppress-warnings.ts │ │ └── index.test.tsx │ ├── .eslintrc.cjs │ ├── tsconfig.json │ ├── tsconfig.eslint.json │ ├── README.md │ ├── package.json │ └── .gitignore └── preact-compostate │ ├── pridepack.json │ ├── src │ ├── index.ts │ ├── defineComponent.tsx │ ├── composition.ts │ └── useCompostateSetup.tsx │ ├── test │ ├── suppress-warnings.ts │ └── index.test.tsx │ ├── .eslintrc.cjs │ ├── tsconfig.json │ ├── tsconfig.eslint.json │ ├── README.md │ ├── package.json │ └── .gitignore ├── pnpm-workspace.yaml ├── examples ├── preact-compostate-vite │ ├── src │ │ ├── preact.d.ts │ │ ├── main.tsx │ │ ├── App2.tsx │ │ └── App3.tsx │ ├── .gitignore │ ├── sandbox.config.json │ ├── vite.config.ts │ ├── .eslintrc.js │ ├── index.html │ ├── package.json │ └── tsconfig.json ├── compostate-element-vite │ ├── src │ │ ├── vite-env.d.ts │ │ ├── style.css │ │ └── main.ts │ ├── .gitignore │ ├── .eslintrc.js │ ├── index.html │ ├── tsconfig.json │ ├── tsconfig.eslint.json │ ├── package.json │ └── favicon.svg └── react-compostate-vite │ ├── .gitignore │ ├── sandbox.config.json │ ├── vite.config.ts │ ├── .eslintrc.js │ ├── src │ ├── main.tsx │ ├── App2.tsx │ └── App3.tsx │ ├── index.html │ ├── tsconfig.json │ └── package.json ├── .eslintrc ├── package.json ├── lerna.json ├── LICENSE ├── .gitignore └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false -------------------------------------------------------------------------------- /packages/compostate/pridepack.json: -------------------------------------------------------------------------------- 1 | { 2 | "target": "es2017" 3 | } -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/**/*' 3 | - 'examples/**/*' -------------------------------------------------------------------------------- /examples/preact-compostate-vite/src/preact.d.ts: -------------------------------------------------------------------------------- 1 | import JSX = preact.JSX 2 | -------------------------------------------------------------------------------- /packages/compostate-element/pridepack.json: -------------------------------------------------------------------------------- 1 | { 2 | "target": "es2017" 3 | } -------------------------------------------------------------------------------- /packages/react-compostate/pridepack.json: -------------------------------------------------------------------------------- 1 | { 2 | "target": "es2017" 3 | } -------------------------------------------------------------------------------- /examples/compostate-element-vite/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/compostate-element-vite/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local -------------------------------------------------------------------------------- /examples/preact-compostate-vite/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | -------------------------------------------------------------------------------- /examples/react-compostate-vite/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | -------------------------------------------------------------------------------- /packages/preact-compostate/pridepack.json: -------------------------------------------------------------------------------- 1 | { 2 | "jsxFactory": "h", 3 | "jsxFragment": "Fragment", 4 | "target": "es2017" 5 | } -------------------------------------------------------------------------------- /packages/compostate-element/test/index.test.ts: -------------------------------------------------------------------------------- 1 | import add from '../src'; 2 | 3 | describe('blah', () => { 4 | it('works', () => { 5 | expect(add(1, 1)).toEqual(2); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /examples/preact-compostate-vite/sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "infiniteLoopProtection": true, 3 | "hardReloadOnChange": false, 4 | "view": "browser", 5 | "container": { 6 | "node": "12" 7 | } 8 | } -------------------------------------------------------------------------------- /examples/react-compostate-vite/sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "infiniteLoopProtection": true, 3 | "hardReloadOnChange": false, 4 | "view": "browser", 5 | "container": { 6 | "node": "12" 7 | } 8 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parserOptions": { 4 | "project": [ 5 | "./packages/*/tsconfig.eslint.json" 6 | ] 7 | }, 8 | "extends": [ 9 | "lxsmnsyc/typescript" 10 | ] 11 | } -------------------------------------------------------------------------------- /examples/preact-compostate-vite/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import preact from '@preact/preset-vite' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [preact()] 7 | }) 8 | -------------------------------------------------------------------------------- /examples/preact-compostate-vite/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { render } from 'preact'; 2 | import App2 from './App2'; 3 | import App3 from './App3'; 4 | 5 | render( 6 | <> 7 | 8 | 9 | , document.getElementById('app')!); 10 | -------------------------------------------------------------------------------- /examples/react-compostate-vite/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import reactRefresh from '@vitejs/plugin-react-refresh' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [reactRefresh()] 7 | }) 8 | -------------------------------------------------------------------------------- /examples/preact-compostate-vite/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | 'lxsmnsyc/typescript/react', 4 | ], 5 | "parserOptions": { 6 | "project": "./tsconfig.json", 7 | "tsconfigRootDir": __dirname, 8 | }, 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /examples/react-compostate-vite/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | 'lxsmnsyc/typescript/react', 4 | ], 5 | "parserOptions": { 6 | "project": "./tsconfig.json", 7 | "tsconfigRootDir": __dirname, 8 | }, 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /examples/compostate-element-vite/src/style.css: -------------------------------------------------------------------------------- 1 | #app { 2 | font-family: Avenir, Helvetica, Arial, sans-serif; 3 | -webkit-font-smoothing: antialiased; 4 | -moz-osx-font-smoothing: grayscale; 5 | text-align: center; 6 | color: #2c3e50; 7 | margin-top: 60px; 8 | } 9 | -------------------------------------------------------------------------------- /packages/compostate-element/src/utils/kebabify.ts: -------------------------------------------------------------------------------- 1 | export default function kebabify(str: string): string { 2 | return str.replace(/([A-Z])([A-Z])/g, '$1-$2') 3 | .replace(/([a-z])([A-Z])/g, '$1-$2') 4 | .replace(/[\s_]+/g, '-') 5 | .toLowerCase(); 6 | } 7 | -------------------------------------------------------------------------------- /packages/compostate-element/src/index.ts: -------------------------------------------------------------------------------- 1 | export { setRenderer } from './renderer'; 2 | export { 3 | onAdopted, 4 | onConnected, 5 | onDisconnected, 6 | onUpdated, 7 | } from './composition'; 8 | export { default as define } from './define'; 9 | export * from './define'; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*", 6 | "examples/*" 7 | ], 8 | "devDependencies": { 9 | "eslint": "^8.22.0", 10 | "eslint-config-lxsmnsyc": "^0.4.8", 11 | "lerna": "^5.4.3", 12 | "typescript": "^4.7.4" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/react-compostate-vite/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App2 from './App2'; 4 | import App3 from './App3'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | 10 | , 11 | document.getElementById('root'), 12 | ); 13 | -------------------------------------------------------------------------------- /packages/react-compostate/src/defineComponent.tsx: -------------------------------------------------------------------------------- 1 | import useCompostateSetup, { CompostateSetup } from './useCompostateSetup'; 2 | 3 | export default function defineComponent>( 4 | setup: CompostateSetup, 5 | ) { 6 | return (props: Props): JSX.Element => useCompostateSetup(setup, props); 7 | } 8 | -------------------------------------------------------------------------------- /packages/preact-compostate/src/index.ts: -------------------------------------------------------------------------------- 1 | // Composition API 2 | export { 3 | onEffect, 4 | onMounted, 5 | onUnmounted, 6 | onUpdated, 7 | } from './composition'; 8 | export { default as defineComponent } from './defineComponent'; 9 | export { 10 | default as useCompostateSetup, 11 | CompostateSetup, 12 | } from './useCompostateSetup'; 13 | -------------------------------------------------------------------------------- /packages/react-compostate/src/index.ts: -------------------------------------------------------------------------------- 1 | // Composition API 2 | export { 3 | onEffect, 4 | onMounted, 5 | onUnmounted, 6 | onUpdated, 7 | } from './composition'; 8 | export { default as defineComponent } from './defineComponent'; 9 | export { 10 | default as useCompostateSetup, 11 | CompostateSetup, 12 | } from './useCompostateSetup'; 13 | -------------------------------------------------------------------------------- /packages/preact-compostate/src/defineComponent.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from 'preact'; 2 | import useCompostateSetup, { CompostateSetup } from './useCompostateSetup'; 3 | 4 | export default function defineComponent>( 5 | setup: CompostateSetup, 6 | ) { 7 | return (props: Props): JSX.Element => useCompostateSetup(setup, props); 8 | } 9 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "useWorkspaces": true, 3 | "packages": [ 4 | "packages/*", 5 | "examples/*" 6 | ], 7 | "command": { 8 | "version": { 9 | "exact": true 10 | }, 11 | "publish": { 12 | "allowBranch": [ 13 | "main", 14 | "vdom", 15 | "no-vdom" 16 | ], 17 | "registry": "https://registry.npmjs.org/" 18 | } 19 | }, 20 | "version": "0.5.1" 21 | } 22 | -------------------------------------------------------------------------------- /packages/compostate-element/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "root": true, 3 | "extends": [ 4 | 'lxsmnsyc/typescript', 5 | ], 6 | "parserOptions": { 7 | "project": "./tsconfig.eslint.json", 8 | "tsconfigRootDir": __dirname, 9 | }, 10 | "rules": { 11 | "import/no-extraneous-dependencies": [ 12 | "error", { 13 | "devDependencies": ["**/*.test.tsx"] 14 | } 15 | ], 16 | }, 17 | }; -------------------------------------------------------------------------------- /examples/compostate-element-vite/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "root": true, 3 | "extends": [ 4 | 'lxsmnsyc/typescript', 5 | ], 6 | "parserOptions": { 7 | "project": "./tsconfig.eslint.json", 8 | "tsconfigRootDir": __dirname, 9 | }, 10 | "rules": { 11 | "import/no-extraneous-dependencies": [ 12 | "error", { 13 | "devDependencies": ["**/*.test.tsx"] 14 | } 15 | ], 16 | }, 17 | }; -------------------------------------------------------------------------------- /examples/compostate-element-vite/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/preact-compostate-vite/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/react-compostate-vite/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/preact-compostate/test/suppress-warnings.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const actualError = console.error; 3 | 4 | const ENABLED = true; 5 | 6 | function defaultError(): void { 7 | // Consume 8 | } 9 | 10 | export function supressWarnings(): void { 11 | if (ENABLED) { 12 | console.error = defaultError; 13 | } 14 | } 15 | 16 | export function restoreWarnings(): void { 17 | if (ENABLED) { 18 | console.error = actualError; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/react-compostate/test/suppress-warnings.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const actualError = console.error; 3 | 4 | const ENABLED = true; 5 | 6 | function defaultError(): void { 7 | // Consume 8 | } 9 | 10 | export function supressWarnings(): void { 11 | if (ENABLED) { 12 | console.error = defaultError; 13 | } 14 | } 15 | 16 | export function restoreWarnings(): void { 17 | if (ENABLED) { 18 | console.error = actualError; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/compostate-element-vite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "lib": ["ESNext", "DOM"], 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "sourceMap": true, 9 | "resolveJsonModule": true, 10 | "esModuleInterop": true, 11 | "noEmit": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true 15 | }, 16 | "include": ["./src"] 17 | } 18 | -------------------------------------------------------------------------------- /examples/compostate-element-vite/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "lib": ["ESNext", "DOM"], 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "sourceMap": true, 9 | "resolveJsonModule": true, 10 | "esModuleInterop": true, 11 | "noEmit": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true 15 | }, 16 | "include": ["./src"] 17 | } 18 | -------------------------------------------------------------------------------- /packages/preact-compostate/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | 'lxsmnsyc/typescript/preact', 4 | ], 5 | "parserOptions": { 6 | "project": "./tsconfig.eslint.json", 7 | "tsconfigRootDir": __dirname, 8 | }, 9 | "rules": { 10 | "import/no-extraneous-dependencies": [ 11 | "error", { 12 | "devDependencies": ["**/*.test.tsx"] 13 | } 14 | ], 15 | "@typescript-eslint/no-unsafe-return": "off", 16 | "@typescript-eslint/no-unsafe-assignment": "off" 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /packages/react-compostate/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | 'lxsmnsyc/typescript/preact', 4 | ], 5 | "parserOptions": { 6 | "project": "./tsconfig.eslint.json", 7 | "tsconfigRootDir": __dirname, 8 | }, 9 | "rules": { 10 | "import/no-extraneous-dependencies": [ 11 | "error", { 12 | "devDependencies": ["**/*.test.tsx"] 13 | } 14 | ], 15 | "@typescript-eslint/no-unsafe-return": "off", 16 | "@typescript-eslint/no-unsafe-assignment": "off" 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /packages/compostate-element/src/renderer.ts: -------------------------------------------------------------------------------- 1 | export type Renderer = (root: ShadowRoot, result: RenderResult) => void; 2 | 3 | let RENDERER: Renderer; 4 | 5 | export function setRenderer(renderer: Renderer): void { 6 | RENDERER = renderer; 7 | } 8 | 9 | export function render(root: ShadowRoot, result: RenderResult): void { 10 | if (RENDERER) { 11 | RENDERER(root, result); 12 | } else { 13 | throw new Error(` 14 | Attempted to render before renderer is defined. 15 | 16 | Make sure that 'setRenderer' has been called first. 17 | `); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/react-compostate-vite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "types": ["vite/client"], 6 | "allowJs": false, 7 | "skipLibCheck": false, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react" 18 | }, 19 | "include": ["./src"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/compostate/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "node", 17 | "jsx": "react", 18 | "esModuleInterop": true, 19 | "target": "ES2017" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/compostate-element/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "node", 17 | "jsx": "react", 18 | "esModuleInterop": true, 19 | "target": "ES2017" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/react-compostate/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "ESNext", 6 | "lib": ["DOM", "ESNext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "node", 17 | "jsx": "react", 18 | "esModuleInterop": true, 19 | "target": "ES2017" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/compostate/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "include": ["src", "types", "test"], 4 | "compilerOptions": { 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "node", 17 | "jsx": "react", 18 | "esModuleInterop": true, 19 | "target": "ES2017" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/react-compostate/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "include": ["src", "types", "test"], 4 | "compilerOptions": { 5 | "module": "ESNext", 6 | "lib": ["DOM", "ESNext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "node", 17 | "jsx": "react", 18 | "esModuleInterop": true, 19 | "target": "ES2017" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/compostate-element/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "include": ["src", "types", "test"], 4 | "compilerOptions": { 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "node", 17 | "jsx": "react", 18 | "esModuleInterop": true, 19 | "target": "ES2017" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/compostate-element-vite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.5.1", 3 | "name": "compostate-element-vite", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "serve": "vite preview" 9 | }, 10 | "dependencies": { 11 | "compostate": "0.5.1", 12 | "compostate-element": "0.5.1", 13 | "lit-html": "^1.4.1" 14 | }, 15 | "devDependencies": { 16 | "eslint": "^8.22.0", 17 | "eslint-config-lxsmnsyc": "^0.4.8", 18 | "typescript": "^4.7.4", 19 | "vite": "^3.0.9" 20 | }, 21 | "private": true, 22 | "publishConfig": { 23 | "access": "restricted" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/preact-compostate-vite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "preact-compostate-vite", 3 | "version": "0.5.1", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "serve": "vite preview" 8 | }, 9 | "dependencies": { 10 | "compostate": "0.5.1", 11 | "preact": "^10.7.2", 12 | "preact-compostate": "0.5.1" 13 | }, 14 | "devDependencies": { 15 | "@preact/preset-vite": "^2.3.0", 16 | "eslint": "^8.22.0", 17 | "eslint-config-lxsmnsyc": "^0.4.8", 18 | "typescript": "^4.7.4", 19 | "vite": "^3.0.9" 20 | }, 21 | "private": true, 22 | "publishConfig": { 23 | "access": "restricted" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/preact-compostate-vite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "types": ["vite/client"], 6 | "allowJs": false, 7 | "skipLibCheck": false, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "preserve", 18 | "jsxFactory": "h", 19 | "jsxFragmentFactory": "Fragment" 20 | }, 21 | "include": ["src"] 22 | } 23 | -------------------------------------------------------------------------------- /packages/preact-compostate/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "ESNext", 6 | "lib": ["DOM", "ESNext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "node", 17 | "jsx": "react", 18 | "esModuleInterop": true, 19 | "target": "ES2017", 20 | "jsxFactory": "h", 21 | "jsxFragmentFactory": "Fragment" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/preact-compostate/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "include": ["src", "types", "test"], 4 | "compilerOptions": { 5 | "module": "ESNext", 6 | "lib": ["DOM", "ESNext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "node", 17 | "jsx": "react", 18 | "esModuleInterop": true, 19 | "target": "ES2017", 20 | "jsxFactory": "h", 21 | "jsxFragmentFactory": "Fragment" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/compostate/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "root": true, 3 | "extends": [ 4 | 'lxsmnsyc/typescript', 5 | ], 6 | "parserOptions": { 7 | "project": "./tsconfig.eslint.json", 8 | "tsconfigRootDir": __dirname, 9 | }, 10 | "rules": { 11 | "import/no-extraneous-dependencies": [ 12 | "error", { 13 | "devDependencies": ["**/*.test.tsx"] 14 | } 15 | ], 16 | "@typescript-eslint/no-unsafe-return": "off", 17 | "@typescript-eslint/no-unsafe-assignment": "off", 18 | "import/no-mutable-exports": "off", 19 | "no-param-reassign": "off", 20 | "no-plusplus": "off", 21 | "@typescript-eslint/ban-types": "off", 22 | "no-restricted-syntax": "off" 23 | } 24 | }; -------------------------------------------------------------------------------- /examples/react-compostate-vite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-compostate-vite", 3 | "version": "0.5.1", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "serve": "vite preview" 8 | }, 9 | "dependencies": { 10 | "compostate": "0.5.1", 11 | "react": "^18.1.0", 12 | "react-compostate": "0.5.1", 13 | "react-dom": "^18.1.0" 14 | }, 15 | "devDependencies": { 16 | "@types/react": "^18.0.9", 17 | "@types/react-dom": "^18.0.4", 18 | "@vitejs/plugin-react": "^1.3.2", 19 | "eslint": "^8.22.0", 20 | "eslint-config-lxsmnsyc": "^0.4.8", 21 | "typescript": "^4.7.4", 22 | "vite": "^3.0.9" 23 | }, 24 | "private": true, 25 | "publishConfig": { 26 | "access": "restricted" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/readonly.ts: -------------------------------------------------------------------------------- 1 | import { ReactiveBaseObject } from './types'; 2 | 3 | export const READONLY = Symbol('COMPOSTATE_READONLY'); 4 | 5 | export type WithReadonly = { 6 | [READONLY]: boolean; 7 | }; 8 | 9 | export function isReadonly(object: T): object is Readonly { 10 | return object && typeof object === 'object' && READONLY in object; 11 | } 12 | 13 | const HANDLER = { 14 | set() { 15 | return true; 16 | }, 17 | }; 18 | 19 | export function readonly(object: T): T { 20 | if (isReadonly(object)) { 21 | return object; 22 | } 23 | const newReadonly = new Proxy(object, HANDLER); 24 | (newReadonly as WithReadonly)[READONLY] = true; 25 | return newReadonly; 26 | } 27 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/trackable.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ReactiveAtom, 3 | TRACKING, 4 | trackReactiveAtom, 5 | } from './core'; 6 | 7 | export const TRACKABLE = Symbol('COMPOSTATE_TRACKABLE'); 8 | 9 | export type WithTrackable = { 10 | [TRACKABLE]: ReactiveAtom | undefined; 11 | }; 12 | 13 | export function registerTrackable( 14 | instance: ReactiveAtom, 15 | trackable: T, 16 | ): T { 17 | (trackable as unknown as WithTrackable)[TRACKABLE] = instance; 18 | return trackable; 19 | } 20 | 21 | export function isTrackable( 22 | trackable: T, 23 | ): boolean { 24 | return trackable && typeof trackable === 'object' && TRACKABLE in trackable; 25 | } 26 | 27 | export function getTrackableAtom( 28 | trackable: T, 29 | ): ReactiveAtom | undefined { 30 | return (trackable as unknown as WithTrackable)[TRACKABLE]; 31 | } 32 | 33 | export function track(source: T): T { 34 | if (TRACKING) { 35 | const instance = getTrackableAtom(source); 36 | if (instance) { 37 | trackReactiveAtom(instance); 38 | } 39 | } 40 | return source; 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alexis H. Munsayac 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 | -------------------------------------------------------------------------------- /examples/preact-compostate-vite/src/App2.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, onEffect } from 'preact-compostate'; 2 | import { ref } from 'compostate'; 3 | 4 | interface CounterMessageProps { 5 | value: number; 6 | } 7 | 8 | const CounterMessage = defineComponent((props) => { 9 | onEffect(() => { 10 | console.log('Count: ', props.value); 11 | }); 12 | return () => ( 13 |

{`Count: ${props.value}`}

14 | ); 15 | }); 16 | 17 | const Counter = defineComponent(() => { 18 | const count = ref(0); 19 | 20 | function increment() { 21 | count.value += 1; 22 | } 23 | 24 | function decrement() { 25 | count.value -= 1; 26 | } 27 | 28 | return () => ( 29 | <> 30 | 33 | 36 | 37 | 38 | ); 39 | }); 40 | 41 | export default function App2(): JSX.Element { 42 | return ( 43 | <> 44 |

45 | {'With '} 46 | defineComponent 47 |

48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /examples/react-compostate-vite/src/App2.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, onEffect } from 'react-compostate'; 2 | import { ref } from 'compostate'; 3 | import React from 'react'; 4 | 5 | interface CounterMessageProps { 6 | value: number; 7 | } 8 | 9 | const CounterMessage = defineComponent((props) => { 10 | onEffect(() => { 11 | console.log('Count: ', props.value); 12 | }); 13 | return () => ( 14 |

{`Count: ${props.value}`}

15 | ); 16 | }); 17 | 18 | const Counter = defineComponent(() => { 19 | const count = ref(0); 20 | 21 | function increment() { 22 | count.value += 1; 23 | } 24 | 25 | function decrement() { 26 | count.value -= 1; 27 | } 28 | 29 | return () => ( 30 | <> 31 | 34 | 37 | 38 | 39 | ); 40 | }); 41 | 42 | export default function App2(): JSX.Element { 43 | return ( 44 | <> 45 |

46 | {'With '} 47 | defineComponent 48 |

49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /examples/compostate-element-vite/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ref, effect } from 'compostate'; 2 | import { setRenderer, define } from 'compostate-element'; 3 | import { render, html } from 'lit-html'; 4 | 5 | setRenderer((root, result) => { 6 | render(result, root); 7 | }); 8 | 9 | define({ 10 | name: 'counter-title', 11 | props: ['value'], 12 | setup(props) { 13 | effect(() => { 14 | console.log(`Current count: ${props.value}`); 15 | }); 16 | 17 | return () => ( 18 | html` 19 |

Count: ${props.value}

20 | ` 21 | ); 22 | }, 23 | }); 24 | 25 | define({ 26 | name: 'counter-button', 27 | setup() { 28 | const count = ref(0); 29 | 30 | function increment() { 31 | count.value += 1; 32 | } 33 | 34 | function decrement() { 35 | count.value -= 1; 36 | } 37 | 38 | return () => ( 39 | html` 40 | 41 | 42 | 43 | ` 44 | ); 45 | }, 46 | }); 47 | 48 | define({ 49 | name: 'custom-app', 50 | setup() { 51 | return () => ( 52 | html` 53 | 54 | ` 55 | ); 56 | }, 57 | }); 58 | -------------------------------------------------------------------------------- /examples/compostate-element-vite/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/preact-compostate-vite/src/App3.tsx: -------------------------------------------------------------------------------- 1 | import { onEffect, useCompostateSetup } from 'preact-compostate'; 2 | import { ref } from 'compostate'; 3 | 4 | interface CounterMessageProps { 5 | value: number; 6 | } 7 | 8 | function CounterMessage(props: CounterMessageProps): JSX.Element { 9 | const { value } = useCompostateSetup((reactiveProps) => { 10 | onEffect(() => { 11 | console.log('Count: ', reactiveProps.value); 12 | }); 13 | 14 | return () => ({ 15 | value: reactiveProps.value, 16 | }); 17 | }, props); 18 | return ( 19 |

{`Count: ${value}`}

20 | ); 21 | } 22 | 23 | function Counter(): JSX.Element { 24 | const counter = useCompostateSetup(() => { 25 | const count = ref(0); 26 | 27 | onEffect(() => { 28 | console.log('Count: ', count.value); 29 | }); 30 | 31 | function increment() { 32 | count.value += 1; 33 | } 34 | 35 | function decrement() { 36 | count.value -= 1; 37 | } 38 | 39 | return () => ({ 40 | increment, 41 | decrement, 42 | value: count.value, 43 | }); 44 | }, {}); 45 | 46 | return ( 47 | <> 48 | 51 | 54 | 55 | 56 | ); 57 | } 58 | 59 | export default function App2(): JSX.Element { 60 | return ( 61 | <> 62 |

63 | {'With '} 64 | useCompostateSetup 65 |

66 | 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /examples/react-compostate-vite/src/App3.tsx: -------------------------------------------------------------------------------- 1 | import { onEffect, useCompostateSetup } from 'react-compostate'; 2 | import { ref } from 'compostate'; 3 | import React from 'react'; 4 | 5 | interface CounterMessageProps { 6 | value: number; 7 | } 8 | 9 | function CounterMessage(props: CounterMessageProps): JSX.Element { 10 | const { value } = useCompostateSetup((reactiveProps) => { 11 | onEffect(() => { 12 | console.log('Count: ', reactiveProps.value); 13 | }); 14 | 15 | return () => ({ 16 | value: reactiveProps.value, 17 | }); 18 | }, props); 19 | return ( 20 |

{`Count: ${value}`}

21 | ); 22 | } 23 | 24 | function Counter(): JSX.Element { 25 | const counter = useCompostateSetup(() => { 26 | const count = ref(0); 27 | 28 | onEffect(() => { 29 | console.log('Count: ', count.value); 30 | }); 31 | 32 | function increment() { 33 | count.value += 1; 34 | } 35 | 36 | function decrement() { 37 | count.value -= 1; 38 | } 39 | 40 | return () => ({ 41 | increment, 42 | decrement, 43 | value: count.value, 44 | }); 45 | }, {}); 46 | 47 | return ( 48 | <> 49 | 52 | 55 | 56 | 57 | ); 58 | } 59 | 60 | export default function App2(): JSX.Element { 61 | return ( 62 | <> 63 |

64 | {'With '} 65 | useCompostateSetup 66 |

67 | 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/template.ts: -------------------------------------------------------------------------------- 1 | import { computed } from './core'; 2 | import { computedRef, isRef } from './refs'; 3 | import { Ref } from './types'; 4 | 5 | function isLazy(value: any): value is () => T { 6 | return typeof value === 'function'; 7 | } 8 | export function templateRef( 9 | strings: TemplateStringsArray, 10 | ...args: (T | Ref | (() => T))[] 11 | ): Ref { 12 | return computedRef(() => { 13 | let result = ''; 14 | let a = 0; 15 | for (let i = 0, len = strings.length; i < len; i++) { 16 | result = `${result}${strings[i]}`; 17 | if (a < args.length) { 18 | const node = args[a++]; 19 | if (isRef(node)) { 20 | result = `${result}${String(node.value)}`; 21 | } else if (isLazy(node)) { 22 | result = `${result}${String(node())}`; 23 | } else { 24 | result = `${result}${String(node)}`; 25 | } 26 | } 27 | } 28 | return result; 29 | }); 30 | } 31 | 32 | export function template( 33 | strings: TemplateStringsArray, 34 | ...args: (T | Ref | (() => T))[] 35 | ): () => string { 36 | return computed(() => { 37 | let result = ''; 38 | let a = 0; 39 | for (let i = 0, len = strings.length; i < len; i++) { 40 | result = `${result}${strings[i]}`; 41 | if (a < args.length) { 42 | const node = args[a++]; 43 | if (isRef(node)) { 44 | result = `${result}${String(node.value)}`; 45 | } else if (isLazy(node)) { 46 | result = `${result}${String(node())}`; 47 | } else { 48 | result = `${result}${String(node)}`; 49 | } 50 | } 51 | } 52 | return result; 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /packages/compostate/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.5.1", 3 | "type": "module", 4 | "types": "./dist/types/index.d.ts", 5 | "main": "./dist/cjs/production/index.cjs", 6 | "module": "./dist/esm/production/index.mjs", 7 | "exports": { 8 | ".": { 9 | "development": { 10 | "require": "./dist/cjs/development/index.cjs", 11 | "import": "./dist/esm/development/index.mjs" 12 | }, 13 | "require": "./dist/cjs/production/index.cjs", 14 | "import": "./dist/esm/production/index.mjs", 15 | "types": "./dist/types/index.d.ts" 16 | } 17 | }, 18 | "files": [ 19 | "dist", 20 | "src" 21 | ], 22 | "engines": { 23 | "node": ">=10" 24 | }, 25 | "license": "MIT", 26 | "keywords": [ 27 | "pridepack" 28 | ], 29 | "name": "compostate", 30 | "devDependencies": { 31 | "@types/node": "^18.7.8", 32 | "eslint": "^8.22.0", 33 | "eslint-config-lxsmnsyc": "^0.4.8", 34 | "pridepack": "^2.3.0", 35 | "tslib": "^2.4.0", 36 | "typescript": "^4.7.4" 37 | }, 38 | "scripts": { 39 | "prepublish": "pridepack clean && pridepack build", 40 | "build": "pridepack build", 41 | "type-check": "pridepack check", 42 | "lint": "pridepack lint", 43 | "clean": "pridepack clean", 44 | "watch": "pridepack watch" 45 | }, 46 | "description": "Composable and reactive state management library", 47 | "repository": { 48 | "url": "https://github.com/lxsmnsyc/compostate.git", 49 | "type": "git" 50 | }, 51 | "homepage": "https://github.com/lxsmnsyc/compostate", 52 | "bugs": { 53 | "url": "https://github.com/lxsmnsyc/compostate/issues" 54 | }, 55 | "publishConfig": { 56 | "access": "public" 57 | }, 58 | "author": "Alexis Munsayac", 59 | "private": false, 60 | "typesVersions": { 61 | "*": {} 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/compostate-element/src/composition.ts: -------------------------------------------------------------------------------- 1 | import { 2 | contextual, 3 | createContext, 4 | inject, 5 | provide, 6 | } from 'compostate'; 7 | 8 | interface DOMContextMethods { 9 | connected(): void; 10 | disconnected(): void; 11 | adopted(): void; 12 | updated(): void; 13 | } 14 | 15 | type DOMContextKeys = keyof DOMContextMethods; 16 | 17 | export type DOMContext = { 18 | [key in DOMContextKeys]: DOMContextMethods[key][]; 19 | }; 20 | 21 | const DOM_CONTEXT = createContext(undefined); 22 | 23 | export function createDOMContext(cb: () => T): T { 24 | return contextual(() => { 25 | provide(DOM_CONTEXT, { 26 | connected: [], 27 | disconnected: [], 28 | adopted: [], 29 | updated: [], 30 | }); 31 | return cb(); 32 | }); 33 | } 34 | 35 | export function runContext( 36 | context: DOMContext, 37 | key: K, 38 | ): void { 39 | const method = context[key]; 40 | for (let i = 0, len = method.length; i < len; i += 1) { 41 | method[i](); 42 | } 43 | } 44 | 45 | export function getDOMContext(): DOMContext { 46 | const context = inject(DOM_CONTEXT); 47 | if (context) { 48 | return context; 49 | } 50 | throw new Error('Attempt to read DOMContext'); 51 | } 52 | 53 | export function onConnected(callback: DOMContextMethods['connected']): void { 54 | getDOMContext().connected.push(callback); 55 | } 56 | 57 | export function onDisconnected(callback: DOMContextMethods['disconnected']): void { 58 | getDOMContext().disconnected.push(callback); 59 | } 60 | 61 | export function onUpdated(callback: DOMContextMethods['updated']): void { 62 | getDOMContext().updated.push(callback); 63 | } 64 | 65 | export function onAdopted(callback: DOMContextMethods['adopted']): void { 66 | getDOMContext().adopted.push(callback); 67 | } 68 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * MIT License 4 | * 5 | * Copyright (c) 2021 Alexis Munsayac 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * 24 | * 25 | * @author Alexis Munsayac 26 | * @copyright Alexis Munsayac 2021 27 | */ 28 | export type ReactiveObject = 29 | | Record 30 | | any[]; 31 | 32 | export type ReactiveCollection = 33 | | Map 34 | | WeakMap 35 | | Set 36 | | WeakSet 37 | 38 | export type ReactiveBaseObject = ReactiveObject | ReactiveCollection; 39 | 40 | export type Cleanup = () => void; 41 | export type Effect = () => void; 42 | 43 | export type ErrorCapture = (error: unknown) => void; 44 | 45 | export interface Ref { 46 | value: T; 47 | } 48 | -------------------------------------------------------------------------------- /packages/react-compostate/README.md: -------------------------------------------------------------------------------- 1 | # react-compostate 2 | 3 | > React bindings for [compostate](https://github.com/lxsmnsyc/compostate/tree/main/packages/compostate) 4 | 5 | [![NPM](https://img.shields.io/npm/v/react-compostate.svg)](https://www.npmjs.com/package/react-compostate) [![JavaScript Style Guide](https://badgen.net/badge/code%20style/airbnb/ff5a5f?icon=airbnb)](https://github.com/airbnb/javascript) 6 | 7 | ## Install 8 | 9 | ```bash 10 | npm install --save compostate react-compostate 11 | ``` 12 | 13 | ```bash 14 | yarn add compostate react-compostate 15 | ``` 16 | 17 | ```bash 18 | pnpm add compostate react-compostate 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```js 24 | import { defineComponent, onEffect } from 'react-compostate'; 25 | import { ref } from 'compostate'; 26 | 27 | // Define a component 28 | const CounterMessage = defineComponent((props) => { 29 | // This function only runs once, hooks cannot be used here. 30 | 31 | // react-compostate provides `onEffect` as a lifecycle hook 32 | // You can use this instead of tracking API like `effect` 33 | onEffect(() => { 34 | console.log('Count: ', props.value); 35 | }); 36 | 37 | // Return the render atom 38 | return () => ( 39 |

{`Count: ${props.value}`}

40 | ); 41 | }); 42 | 43 | const Counter = defineComponent(() => { 44 | const count = ref(0); 45 | 46 | function increment() { 47 | count.value += 1; 48 | } 49 | 50 | function decrement() { 51 | count.value -= 1; 52 | } 53 | 54 | return () => ( 55 | <> 56 | 59 | 62 | 63 | 64 | ); 65 | }); 66 | ``` 67 | 68 | ## License 69 | 70 | MIT © [lxsmnsyc](https://github.com/lxsmnsyc) 71 | -------------------------------------------------------------------------------- /packages/preact-compostate/README.md: -------------------------------------------------------------------------------- 1 | # preact-compostate 2 | 3 | > Preact bindings for [compostate](https://github.com/lxsmnsyc/compostate/tree/main/packages/compostate) 4 | 5 | [![NPM](https://img.shields.io/npm/v/preact-compostate.svg)](https://www.npmjs.com/package/preact-compostate) [![JavaScript Style Guide](https://badgen.net/badge/code%20style/airbnb/ff5a5f?icon=airbnb)](https://github.com/airbnb/javascript) 6 | 7 | ## Install 8 | 9 | ```bash 10 | npm install --save compostate preact-compostate 11 | ``` 12 | 13 | ```bash 14 | yarn add compostate preact-compostate 15 | ``` 16 | 17 | ```bash 18 | pnpm add compostate preact-compostate 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```js 24 | import { defineComponent, onEffect } from 'preact-compostate'; 25 | import { ref } from 'compostate'; 26 | 27 | // Define a component 28 | const CounterMessage = defineComponent((props) => { 29 | // This function only runs once, hooks cannot be used here. 30 | 31 | // preact-compostate provides `onEffect` as a lifecycle hook 32 | // You can use this instead of tracking API like `effect` 33 | onEffect(() => { 34 | console.log('Count: ', props.value); 35 | }); 36 | 37 | // Return the render atom 38 | return () => ( 39 |

{`Count: ${props.value}`}

40 | ); 41 | }); 42 | 43 | const Counter = defineComponent(() => { 44 | const count = ref(0); 45 | 46 | function increment() { 47 | count.value += 1; 48 | } 49 | 50 | function decrement() { 51 | count.value -= 1; 52 | } 53 | 54 | return () => ( 55 | <> 56 | 59 | 62 | 63 | 64 | ); 65 | }); 66 | ``` 67 | 68 | ## License 69 | 70 | MIT © [lxsmnsyc](https://github.com/lxsmnsyc) 71 | -------------------------------------------------------------------------------- /packages/compostate-element/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.5.1", 3 | "type": "module", 4 | "types": "./dist/types/index.d.ts", 5 | "main": "./dist/cjs/production/index.cjs", 6 | "module": "./dist/esm/production/index.mjs", 7 | "exports": { 8 | ".": { 9 | "development": { 10 | "require": "./dist/cjs/development/index.cjs", 11 | "import": "./dist/esm/development/index.mjs" 12 | }, 13 | "require": "./dist/cjs/production/index.cjs", 14 | "import": "./dist/esm/production/index.mjs", 15 | "types": "./dist/types/index.d.ts" 16 | } 17 | }, 18 | "files": [ 19 | "dist", 20 | "src" 21 | ], 22 | "engines": { 23 | "node": ">=10" 24 | }, 25 | "license": "MIT", 26 | "keywords": [ 27 | "pridepack" 28 | ], 29 | "name": "compostate-element", 30 | "devDependencies": { 31 | "@types/node": "^18.7.8", 32 | "compostate": "0.5.1", 33 | "eslint": "^8.22.0", 34 | "eslint-config-lxsmnsyc": "^0.4.8", 35 | "pridepack": "^2.3.0", 36 | "tslib": "^2.4.0", 37 | "typescript": "^4.7.4" 38 | }, 39 | "peerDependencies": { 40 | "compostate": "^0.2.1-beta.0" 41 | }, 42 | "scripts": { 43 | "prepublish": "pridepack clean && pridepack build", 44 | "build": "pridepack build", 45 | "type-check": "pridepack check", 46 | "lint": "pridepack lint", 47 | "clean": "pridepack clean", 48 | "watch": "pridepack watch" 49 | }, 50 | "description": "compostate + Custom Elements", 51 | "repository": "https://github.com/LXSMNSYC/compostate.git", 52 | "bugs": { 53 | "url": "https://github.com/LXSMNSYC/compostate/issues" 54 | }, 55 | "homepage": "https://github.com/LXSMNSYC/compostate/tree/main/packages/compostate-element", 56 | "publishConfig": { 57 | "access": "public" 58 | }, 59 | "author": "Alexis Munsayac ", 60 | "private": false, 61 | "typesVersions": { 62 | "*": {} 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/react-compostate/src/composition.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Effect, 3 | effect, 4 | inject, 5 | createContext, 6 | contextual, 7 | provide, 8 | } from 'compostate'; 9 | 10 | interface CompositionContextMethods { 11 | mounted(): void; 12 | unmounted(): void; 13 | updated(): void; 14 | effect(): void; 15 | } 16 | 17 | type CompositionContextKeys = keyof CompositionContextMethods; 18 | 19 | export type CompositionContext = { 20 | [key in CompositionContextKeys]: CompositionContextMethods[key][]; 21 | }; 22 | 23 | const COMPOSITION_CONTEXT = createContext(undefined); 24 | 25 | export function createCompositionContext(cb: () => T): T { 26 | return contextual(() => { 27 | provide(COMPOSITION_CONTEXT, { 28 | mounted: [], 29 | unmounted: [], 30 | effect: [], 31 | updated: [], 32 | }); 33 | return cb(); 34 | }); 35 | } 36 | 37 | export function getCompositionContext(): CompositionContext { 38 | const context = inject(COMPOSITION_CONTEXT); 39 | if (context) { 40 | return context; 41 | } 42 | throw new Error('Attempt to read DOMContext'); 43 | } 44 | 45 | export function runCompositionContext( 46 | context: CompositionContext, 47 | key: K, 48 | ): void { 49 | const method = context[key]; 50 | for (let i = 0, len = method.length; i < len; i += 1) { 51 | method[i](); 52 | } 53 | } 54 | 55 | export function onEffect(callback: Effect): void { 56 | getCompositionContext().effect.push(() => { 57 | effect(callback); 58 | }); 59 | } 60 | 61 | export function onMounted(callback: CompositionContextMethods['mounted']): void { 62 | getCompositionContext().mounted.push(callback); 63 | } 64 | 65 | export function onUnmounted(callback: CompositionContextMethods['unmounted']): void { 66 | getCompositionContext().unmounted.push(callback); 67 | } 68 | 69 | export function onUpdated(callback: CompositionContextMethods['updated']): void { 70 | getCompositionContext().updated.push(callback); 71 | } 72 | -------------------------------------------------------------------------------- /packages/preact-compostate/src/composition.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Effect, 3 | effect, 4 | inject, 5 | createContext, 6 | contextual, 7 | provide, 8 | } from 'compostate'; 9 | 10 | interface CompositionContextMethods { 11 | mounted(): void; 12 | unmounted(): void; 13 | updated(): void; 14 | effect(): void; 15 | } 16 | 17 | type CompositionContextKeys = keyof CompositionContextMethods; 18 | 19 | export type CompositionContext = { 20 | [key in CompositionContextKeys]: CompositionContextMethods[key][]; 21 | }; 22 | 23 | const COMPOSITION_CONTEXT = createContext(undefined); 24 | 25 | export function createCompositionContext(cb: () => T): T { 26 | return contextual(() => { 27 | provide(COMPOSITION_CONTEXT, { 28 | mounted: [], 29 | unmounted: [], 30 | effect: [], 31 | updated: [], 32 | }); 33 | return cb(); 34 | }); 35 | } 36 | 37 | export function getCompositionContext(): CompositionContext { 38 | const context = inject(COMPOSITION_CONTEXT); 39 | if (context) { 40 | return context; 41 | } 42 | throw new Error('Attempt to read DOMContext'); 43 | } 44 | 45 | export function runCompositionContext( 46 | context: CompositionContext, 47 | key: K, 48 | ): void { 49 | const method = context[key]; 50 | for (let i = 0, len = method.length; i < len; i += 1) { 51 | method[i](); 52 | } 53 | } 54 | 55 | export function onEffect(callback: Effect): void { 56 | getCompositionContext().effect.push(() => { 57 | effect(callback); 58 | }); 59 | } 60 | 61 | export function onMounted(callback: CompositionContextMethods['mounted']): void { 62 | getCompositionContext().mounted.push(callback); 63 | } 64 | 65 | export function onUnmounted(callback: CompositionContextMethods['unmounted']): void { 66 | getCompositionContext().unmounted.push(callback); 67 | } 68 | 69 | export function onUpdated(callback: CompositionContextMethods['updated']): void { 70 | getCompositionContext().updated.push(callback); 71 | } 72 | -------------------------------------------------------------------------------- /packages/preact-compostate/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.5.1", 3 | "type": "module", 4 | "types": "./dist/types/index.d.ts", 5 | "main": "./dist/cjs/production/index.cjs", 6 | "module": "./dist/esm/production/index.mjs", 7 | "exports": { 8 | ".": { 9 | "development": { 10 | "require": "./dist/cjs/development/index.cjs", 11 | "import": "./dist/esm/development/index.mjs" 12 | }, 13 | "require": "./dist/cjs/production/index.cjs", 14 | "import": "./dist/esm/production/index.mjs", 15 | "types": "./dist/types/index.d.ts" 16 | } 17 | }, 18 | "files": [ 19 | "dist", 20 | "src" 21 | ], 22 | "engines": { 23 | "node": ">=10" 24 | }, 25 | "license": "MIT", 26 | "keywords": [ 27 | "pridepack" 28 | ], 29 | "name": "preact-compostate", 30 | "devDependencies": { 31 | "@types/node": "^18.7.8", 32 | "compostate": "0.5.1", 33 | "eslint": "^8.22.0", 34 | "eslint-config-lxsmnsyc": "^0.4.8", 35 | "preact": "^10.7.2", 36 | "pridepack": "^2.3.0", 37 | "tslib": "^2.4.0", 38 | "typescript": "^4.7.4" 39 | }, 40 | "peerDependencies": { 41 | "compostate": "^0.2.1-beta.0", 42 | "preact": "^10.0.0" 43 | }, 44 | "scripts": { 45 | "prepublish": "pridepack clean && pridepack build", 46 | "build": "pridepack build", 47 | "type-check": "pridepack check", 48 | "lint": "pridepack lint", 49 | "clean": "pridepack clean", 50 | "watch": "pridepack watch" 51 | }, 52 | "description": "Compostate bindings for React", 53 | "repository": { 54 | "url": "https://github.com/lxsmnsyc/compostate.git", 55 | "type": "git" 56 | }, 57 | "homepage": "https://github.com/lxsmnsyc/compostate", 58 | "bugs": { 59 | "url": "https://github.com/lxsmnsyc/compostate/issues" 60 | }, 61 | "publishConfig": { 62 | "access": "public" 63 | }, 64 | "author": "Alexis Munsayac", 65 | "private": false, 66 | "dependencies": { 67 | "@lyonph/preact-hooks": "^0.6.0" 68 | }, 69 | "typesVersions": { 70 | "*": {} 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/compostate/src/utils/is-plain-object.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * MIT License 4 | * 5 | * Copyright (c) 2021 Alexis Munsayac 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * 24 | * 25 | * @author Alexis Munsayac 26 | * @copyright Alexis Munsayac 2021 27 | */ 28 | export default function isPlainObject(obj: unknown): obj is Record { 29 | // Basic check for Type object that's not null 30 | if (typeof obj === 'object' && obj !== null) { 31 | // If Object.getPrototypeOf supported, use it 32 | if (typeof Object.getPrototypeOf === 'function') { 33 | const proto = Object.getPrototypeOf(obj); 34 | return proto === Object.prototype || proto === null; 35 | } 36 | 37 | // Otherwise, use internal class 38 | // This should be reliable as if getPrototypeOf not supported, is pre-ES5 39 | return Object.prototype.toString.call(obj) === '[object Object]'; 40 | } 41 | 42 | // Not an object 43 | return false; 44 | } 45 | -------------------------------------------------------------------------------- /packages/react-compostate/test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, act } from '@testing-library/react'; 3 | import { state } from 'compostate'; 4 | 5 | import { CompostateRoot, useCompostate } from '../src'; 6 | 7 | import { supressWarnings, restoreWarnings } from './suppress-warnings'; 8 | 9 | import '@testing-library/jest-dom'; 10 | 11 | beforeEach(() => { 12 | jest.useFakeTimers(); 13 | }); 14 | afterEach(() => { 15 | jest.useRealTimers(); 16 | }); 17 | 18 | describe('useCompostate', () => { 19 | it('should throw an error when used without CompostateRoot', () => { 20 | const example = state(() => 0); 21 | 22 | function Consumer(): JSX.Element { 23 | const value = useCompostate(example); 24 | 25 | return

{value}

; 26 | } 27 | 28 | supressWarnings(); 29 | expect(() => { 30 | render(); 31 | }).toThrowError(); 32 | restoreWarnings(); 33 | }); 34 | it('should receive the correct state on initial render', () => { 35 | const expected = 'Expected'; 36 | const example = state(() => expected); 37 | 38 | function Consumer(): JSX.Element { 39 | const value = useCompostate(example); 40 | 41 | return

{value}

; 42 | } 43 | 44 | const result = render(( 45 | 46 | 47 | 48 | )); 49 | 50 | expect(result.getByTitle('example')).toContainHTML(expected); 51 | }); 52 | it('should receive the updated state', () => { 53 | const example = state(() => 'Initial'); 54 | 55 | function Consumer(): JSX.Element { 56 | const value = useCompostate(example); 57 | 58 | return

{value}

; 59 | } 60 | 61 | const result = render(( 62 | 63 | 64 | 65 | )); 66 | 67 | expect(result.getByTitle('example')).toContainHTML('Initial'); 68 | example.value = 'Updated'; 69 | act(() => { 70 | jest.runAllTimers(); 71 | }); 72 | expect(result.getByTitle('example')).toContainHTML('Updated'); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /packages/preact-compostate/test/index.test.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx h */ 2 | import { h } from 'preact'; 3 | import { render, act } from '@testing-library/preact'; 4 | import { state } from 'compostate'; 5 | 6 | import { CompostateRoot, useCompostate } from '../src'; 7 | 8 | import { supressWarnings, restoreWarnings } from './suppress-warnings'; 9 | 10 | import '@testing-library/jest-dom'; 11 | 12 | beforeEach(() => { 13 | jest.useFakeTimers(); 14 | }); 15 | afterEach(() => { 16 | jest.useRealTimers(); 17 | }); 18 | 19 | describe('useCompostate', () => { 20 | it('should throw an error when used without CompostateRoot', () => { 21 | const example = state(() => 0); 22 | 23 | function Consumer(): JSX.Element { 24 | const value = useCompostate(example); 25 | 26 | return

{value}

; 27 | } 28 | 29 | supressWarnings(); 30 | expect(() => { 31 | render(); 32 | }).toThrowError(); 33 | restoreWarnings(); 34 | }); 35 | it('should receive the correct state on initial render', () => { 36 | const expected = 'Expected'; 37 | const example = state(() => expected); 38 | 39 | function Consumer(): JSX.Element { 40 | const value = useCompostate(example); 41 | 42 | return

{value}

; 43 | } 44 | 45 | const result = render(( 46 | 47 | 48 | 49 | )); 50 | 51 | expect(result.getByTitle('example')).toContainHTML(expected); 52 | }); 53 | it('should receive the updated state', async () => { 54 | const example = state(() => 'Initial'); 55 | 56 | function Consumer(): JSX.Element { 57 | const value = useCompostate(example); 58 | 59 | return

{value}

; 60 | } 61 | 62 | const result = render(( 63 | 64 | 65 | 66 | )); 67 | 68 | expect(result.getByTitle('example')).toContainHTML('Initial'); 69 | example.value = 'Updated'; 70 | await act(() => { 71 | jest.runAllTimers(); 72 | }); 73 | expect(result.getByTitle('example')).toContainHTML('Updated'); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | .parcel-cache 106 | lib 107 | -------------------------------------------------------------------------------- /packages/react-compostate/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.5.1", 3 | "type": "module", 4 | "types": "./dist/types/index.d.ts", 5 | "main": "./dist/cjs/production/index.cjs", 6 | "module": "./dist/esm/production/index.mjs", 7 | "exports": { 8 | ".": { 9 | "development": { 10 | "require": "./dist/cjs/development/index.cjs", 11 | "import": "./dist/esm/development/index.mjs" 12 | }, 13 | "require": "./dist/cjs/production/index.cjs", 14 | "import": "./dist/esm/production/index.mjs", 15 | "types": "./dist/types/index.d.ts" 16 | } 17 | }, 18 | "files": [ 19 | "dist", 20 | "src" 21 | ], 22 | "engines": { 23 | "node": ">=10" 24 | }, 25 | "license": "MIT", 26 | "keywords": [ 27 | "pridepack" 28 | ], 29 | "name": "react-compostate", 30 | "devDependencies": { 31 | "@types/node": "^18.7.8", 32 | "@types/react": "^18.0.9", 33 | "compostate": "0.5.1", 34 | "eslint": "^8.22.0", 35 | "eslint-config-lxsmnsyc": "^0.4.8", 36 | "pridepack": "^2.3.0", 37 | "react": "^18.1.0", 38 | "react-dom": "^18.1.0", 39 | "react-test-renderer": "^18.1.0", 40 | "tslib": "^2.4.0", 41 | "typescript": "^4.7.4" 42 | }, 43 | "peerDependencies": { 44 | "compostate": "^0.2.1-beta.0", 45 | "react": "^16.8 || ^17.0 || ^18.0", 46 | "react-dom": "^16.8 || ^17.0 || ^18.0" 47 | }, 48 | "scripts": { 49 | "prepublish": "pridepack clean && pridepack build", 50 | "build": "pridepack build", 51 | "type-check": "pridepack check", 52 | "lint": "pridepack lint", 53 | "clean": "pridepack clean", 54 | "watch": "pridepack watch" 55 | }, 56 | "description": "Compostate bindings for React", 57 | "repository": { 58 | "url": "https://github.com/lxsmnsyc/compostate.git", 59 | "type": "git" 60 | }, 61 | "homepage": "https://github.com/lxsmnsyc/compostate", 62 | "bugs": { 63 | "url": "https://github.com/lxsmnsyc/compostate/issues" 64 | }, 65 | "publishConfig": { 66 | "access": "public" 67 | }, 68 | "author": "Alexis Munsayac", 69 | "private": false, 70 | "dependencies": { 71 | "@lyonph/react-hooks": "^0.6.0" 72 | }, 73 | "typesVersions": { 74 | "*": {} 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /packages/compostate/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.production 74 | .env.development 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | .npmrc 108 | -------------------------------------------------------------------------------- /packages/compostate-element/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.production 74 | .env.development 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | .npmrc 108 | -------------------------------------------------------------------------------- /packages/preact-compostate/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.production 74 | .env.development 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | .npmrc 108 | -------------------------------------------------------------------------------- /packages/react-compostate/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.production 74 | .env.development 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | .npmrc 108 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/resource.ts: -------------------------------------------------------------------------------- 1 | import { 2 | onCleanup, batch, effect, syncEffect, 3 | } from './core'; 4 | import reactive from './reactive'; 5 | 6 | export interface ResourcePending { 7 | status: 'pending'; 8 | value?: undefined; 9 | } 10 | 11 | export interface ResourceFailure { 12 | status: 'failure'; 13 | value: any; 14 | } 15 | 16 | export interface ResourceSuccess { 17 | status: 'success'; 18 | value: T; 19 | } 20 | 21 | export type Resource = 22 | | ResourcePending 23 | | ResourceFailure 24 | | ResourceSuccess; 25 | 26 | export interface ResourceOptions { 27 | initialValue?: T; 28 | timeoutMS?: number; 29 | } 30 | 31 | export default function resource( 32 | fetcher: () => Promise, 33 | options: ResourceOptions = {}, 34 | ): Resource { 35 | const baseState: Resource = options.initialValue != null 36 | ? { status: 'success', value: options.initialValue } 37 | : { status: 'pending' }; 38 | 39 | const state = reactive>(baseState); 40 | 41 | effect(() => { 42 | let alive = true; 43 | 44 | const promise = fetcher(); 45 | 46 | const stop = syncEffect(() => { 47 | // If there's a transition timeout, 48 | // do not fallback to pending state. 49 | if (options.timeoutMS) { 50 | const timeout = setTimeout(() => { 51 | // Resolution takes too long, 52 | // fallback to pending state. 53 | state.status = 'pending'; 54 | }, options.timeoutMS); 55 | 56 | onCleanup(() => { 57 | clearTimeout(timeout); 58 | }); 59 | } else { 60 | state.status = 'pending'; 61 | } 62 | }); 63 | 64 | promise.then( 65 | (value) => { 66 | if (alive) { 67 | stop(); 68 | batch(() => { 69 | state.status = 'success'; 70 | state.value = value; 71 | }); 72 | } 73 | }, 74 | (value: any) => { 75 | if (alive) { 76 | stop(); 77 | batch(() => { 78 | state.status = 'failure'; 79 | state.value = value; 80 | }); 81 | } 82 | }, 83 | ); 84 | 85 | onCleanup(() => { 86 | alive = false; 87 | }); 88 | }); 89 | 90 | return state; 91 | } 92 | -------------------------------------------------------------------------------- /packages/compostate-element/README.md: -------------------------------------------------------------------------------- 1 | # compostate-element 2 | 3 | > Web Components bindings for [compostate](https://github.com/lxsmnsyc/compostate/tree/main/packages/compostate) 4 | 5 | [![NPM](https://img.shields.io/npm/v/compostate-element.svg)](https://www.npmjs.com/package/compostate-element) [![JavaScript Style Guide](https://badgen.net/badge/code%20style/airbnb/ff5a5f?icon=airbnb)](https://github.com/airbnb/javascript) 6 | 7 | ## Install 8 | 9 | ```bash 10 | npm install --save compostate compostate-element 11 | ``` 12 | 13 | ```bash 14 | yarn add compostate compostate-element 15 | ``` 16 | 17 | ```bash 18 | pnpm add compostate compostate-element 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```js 24 | import { ref, effect } from 'compostate'; 25 | import { setRenderer, define } from 'compostate-element'; 26 | import { render, html } from 'lit-html'; 27 | 28 | // Setup the element's renderer using Lit 29 | // Any renderer should work 30 | setRenderer((root, result) => { 31 | render(result, root); 32 | }); 33 | 34 | // Define an element 35 | define({ 36 | // Name of the element (required) 37 | name: 'counter-title', 38 | // Props to be tracked (required) 39 | props: ['value'], 40 | // Element setup 41 | setup(props) { 42 | // The setup method is run only once 43 | // it's useful to setup your component logic here. 44 | effect(() => { 45 | // Props are reactive 46 | console.log(`Current count: ${props.value}`); 47 | }); 48 | 49 | // Return the template atom 50 | // The template's returned value depends 51 | // on the renderer's templates. 52 | // For example, you can return a JSX markup 53 | // if the renderer used is React or Preact. 54 | return () => ( 55 | html` 56 |

Count: ${props.value}

57 | ` 58 | ); 59 | }, 60 | }); 61 | 62 | define({ 63 | name: 'counter-button', 64 | setup() { 65 | const count = ref(0); 66 | 67 | function increment() { 68 | count.value += 1; 69 | } 70 | 71 | function decrement() { 72 | count.value -= 1; 73 | } 74 | 75 | return () => ( 76 | html` 77 | 78 | 79 | 80 | ` 81 | ); 82 | }, 83 | }); 84 | ``` 85 | 86 | ## License 87 | 88 | MIT © [lxsmnsyc](https://github.com/lxsmnsyc) 89 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/nodes/reactive-weak-keys.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * MIT License 4 | * 5 | * Copyright (c) 2021 Alexis Munsayac 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * 24 | * 25 | * @author Alexis Munsayac 26 | * @copyright Alexis Munsayac 2021 27 | */ 28 | import { 29 | createReactiveAtom, 30 | destroyReactiveAtom, 31 | notifyReactiveAtom, 32 | ReactiveAtom, 33 | trackReactiveAtom, 34 | } from '../core'; 35 | 36 | export type ReactiveWeakKeys = WeakMap; 37 | 38 | export function createReactiveWeakKeys(): ReactiveWeakKeys { 39 | return new WeakMap(); 40 | } 41 | 42 | function getAtom(atoms: ReactiveWeakKeys, key: K): ReactiveAtom { 43 | const current = atoms.get(key); 44 | if (current) { 45 | return current; 46 | } 47 | const atom = createReactiveAtom(); 48 | atoms.set(key, atom); 49 | return atom; 50 | } 51 | 52 | export function notifyReactiveWeakKeys( 53 | atoms: ReactiveWeakKeys, 54 | key: K, 55 | destroy?: boolean, 56 | ): void { 57 | const atom = getAtom(atoms, key); 58 | notifyReactiveAtom(atom); 59 | if (destroy) { 60 | destroyReactiveAtom(atom); 61 | } 62 | } 63 | 64 | export function trackReactiveWeakKeys( 65 | atoms: ReactiveWeakKeys, 66 | key: K, 67 | ): void { 68 | trackReactiveAtom(getAtom(atoms, key)); 69 | } 70 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/debounce.ts: -------------------------------------------------------------------------------- 1 | import { 2 | captured, 3 | captureReactiveAtomForCleanup, 4 | createReactiveAtom, 5 | notifyReactiveAtom, 6 | onCleanup, 7 | syncEffect, 8 | TRACKING, 9 | trackReactiveAtom, 10 | untrack, 11 | watch, 12 | } from './core'; 13 | import { readonly } from './readonly'; 14 | import { REF, WithRef } from './refs'; 15 | import { WithTrackable, TRACKABLE } from './trackable'; 16 | import { Ref } from './types'; 17 | 18 | const { is } = Object; 19 | 20 | export function debouncedRef( 21 | source: () => T, 22 | timeoutMS: number, 23 | isEqual: (next: T, prev: T) => boolean = is, 24 | ): Ref { 25 | const instance = createReactiveAtom(); 26 | captureReactiveAtomForCleanup(instance); 27 | 28 | let value: T; 29 | 30 | const setup = captured(() => { 31 | syncEffect( 32 | watch(source, (next) => { 33 | const timeout = setTimeout(() => { 34 | value = next; 35 | notifyReactiveAtom(instance); 36 | }, timeoutMS); 37 | 38 | onCleanup(() => { 39 | clearTimeout(timeout); 40 | }); 41 | }, isEqual), 42 | ); 43 | }); 44 | 45 | let doSetup = true; 46 | 47 | const node: Ref & WithRef & WithTrackable = readonly({ 48 | [REF]: true, 49 | [TRACKABLE]: instance, 50 | get value(): T { 51 | if (doSetup) { 52 | value = untrack(source); 53 | setup(); 54 | doSetup = false; 55 | } 56 | if (TRACKING) { 57 | trackReactiveAtom(instance); 58 | } 59 | return value; 60 | }, 61 | }); 62 | 63 | return node; 64 | } 65 | 66 | export function debounced( 67 | source: () => T, 68 | timeoutMS: number, 69 | isEqual: (next: T, prev: T) => boolean = is, 70 | ): () => T { 71 | const instance = createReactiveAtom(); 72 | captureReactiveAtomForCleanup(instance); 73 | 74 | let value: T; 75 | 76 | const setup = captured(() => { 77 | syncEffect( 78 | watch(source, (next) => { 79 | const timeout = setTimeout(() => { 80 | value = next; 81 | notifyReactiveAtom(instance); 82 | }, timeoutMS); 83 | 84 | onCleanup(() => { 85 | clearTimeout(timeout); 86 | }); 87 | }, isEqual), 88 | ); 89 | }); 90 | 91 | let doSetup = true; 92 | 93 | return () => { 94 | if (doSetup) { 95 | value = untrack(source); 96 | setup(); 97 | doSetup = false; 98 | } 99 | if (TRACKING) { 100 | trackReactiveAtom(instance); 101 | } 102 | return value; 103 | }; 104 | } 105 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/nodes/reactive-keys.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * MIT License 4 | * 5 | * Copyright (c) 2021 Alexis Munsayac 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * 24 | * 25 | * @author Alexis Munsayac 26 | * @copyright Alexis Munsayac 2021 27 | */ 28 | import { 29 | createReactiveAtom, 30 | destroyReactiveAtom, 31 | notifyReactiveAtom, 32 | ReactiveAtom, 33 | trackReactiveAtom, 34 | } from '../core'; 35 | 36 | export type ReactiveKeys = Map; 37 | 38 | export function createReactiveKeys(): ReactiveKeys { 39 | return new Map(); 40 | } 41 | 42 | export function destroyReactiveKeys(keys: ReactiveKeys): void { 43 | for (const value of keys.values()) { 44 | destroyReactiveAtom(value); 45 | } 46 | } 47 | 48 | function getAtom(atoms: ReactiveKeys, key: K): ReactiveAtom { 49 | const current = atoms.get(key); 50 | if (current) { 51 | return current; 52 | } 53 | const atom = createReactiveAtom(); 54 | atoms.set(key, atom); 55 | return atom; 56 | } 57 | 58 | export function notifyReactiveKeys( 59 | keys: ReactiveKeys, 60 | key: K, 61 | destroy?: boolean, 62 | ): void { 63 | const atom = getAtom(keys, key); 64 | notifyReactiveAtom(atom); 65 | if (destroy) { 66 | destroyReactiveAtom(atom); 67 | } 68 | } 69 | 70 | export function trackReactiveKeys( 71 | keys: ReactiveKeys, 72 | key: K, 73 | ): void { 74 | trackReactiveAtom(getAtom(keys, key)); 75 | } 76 | 77 | export function notifyAllReactiveKeys( 78 | keys: ReactiveKeys, 79 | destroy?: boolean, 80 | ): void { 81 | if (keys.size) { 82 | for (const value of keys.values()) { 83 | notifyReactiveAtom(value); 84 | if (destroy) { 85 | destroyReactiveAtom(value); 86 | } 87 | } 88 | if (destroy) { 89 | keys.clear(); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/reactive.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * MIT License 4 | * 5 | * Copyright (c) 2021 Alexis Munsayac 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * 24 | * 25 | * @author Alexis Munsayac 26 | * @copyright Alexis Munsayac 2021 27 | */ 28 | /* eslint-disable @typescript-eslint/ban-types */ 29 | import isPlainObject from '../utils/is-plain-object'; 30 | import ReactiveMap from './reactive-map'; 31 | import createReactiveObject from './reactive-object'; 32 | import ReactiveSet from './reactive-set'; 33 | import ReactiveWeakMap from './reactive-weak-map'; 34 | import ReactiveWeakSet from './reactive-weak-set'; 35 | 36 | const proxies = new WeakMap(); 37 | 38 | function getReactive(source: unknown): any { 39 | if (source instanceof Map) { 40 | return new ReactiveMap(source); 41 | } 42 | if (source instanceof Set) { 43 | return new ReactiveSet(source); 44 | } 45 | if (source instanceof WeakMap) { 46 | return new ReactiveWeakMap(source); 47 | } 48 | if (source instanceof WeakSet) { 49 | return new ReactiveWeakSet(source); 50 | } 51 | if (Array.isArray(source) || isPlainObject(source)) { 52 | return createReactiveObject(source); 53 | } 54 | throw new Error('invalid reactive source'); 55 | } 56 | 57 | function reactive(source: T): T; 58 | function reactive>(source: T): T; 59 | function reactive(source: Set): ReactiveSet; 60 | function reactive(source: Map): ReactiveMap; 61 | function reactive(source: WeakSet): ReactiveWeakSet; 62 | function reactive(source: WeakMap): ReactiveWeakMap; 63 | function reactive(source: any): any { 64 | const currentProxy = proxies.get(source); 65 | if (currentProxy) { 66 | return currentProxy; 67 | } 68 | 69 | const newProxy = getReactive(source); 70 | proxies.set(source, newProxy); 71 | return newProxy; 72 | } 73 | 74 | export default reactive; 75 | -------------------------------------------------------------------------------- /packages/compostate/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * MIT License 4 | * 5 | * Copyright (c) 2021 Alexis Munsayac 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * 24 | * 25 | * @author Alexis Munsayac 26 | * @copyright Alexis Munsayac 2021 27 | */ 28 | export { 29 | startTransition, 30 | isTransitionPending, 31 | batch, 32 | unbatch, 33 | createRoot, 34 | // captures 35 | capturedBatchCleanup, 36 | capturedErrorBoundary, 37 | capturedContext, 38 | captured, 39 | // cleanup 40 | onCleanup, 41 | batchCleanup, 42 | unbatchCleanup, 43 | // error boundary 44 | onError, 45 | errorBoundary, 46 | captureError, 47 | // computation 48 | computation, 49 | // effects 50 | effect, 51 | syncEffect, 52 | // subscription 53 | untrack, 54 | watch, 55 | // context 56 | contextual, 57 | createContext, 58 | Context, 59 | writeContext as provide, 60 | readContext as inject, 61 | writeContext, 62 | readContext, 63 | // selector 64 | selector, 65 | // atoms 66 | atom, 67 | Atom, 68 | computed, 69 | // signal 70 | signal, 71 | Signal, 72 | // deferred 73 | deferred, 74 | } from './reactivity/core'; 75 | // Extensions 76 | export { 77 | ref, 78 | isRef, 79 | computedRef, 80 | deferredRef, 81 | } from './reactivity/refs'; 82 | export { 83 | readonly, 84 | isReadonly, 85 | } from './reactivity/readonly'; 86 | export { 87 | isTrackable, 88 | track, 89 | } from './reactivity/trackable'; 90 | export { 91 | map, 92 | index, 93 | } from './reactivity/array'; 94 | export { 95 | debounced, 96 | debouncedRef, 97 | } from './reactivity/debounce'; 98 | export { 99 | default as reactive, 100 | } from './reactivity/reactive'; 101 | export { 102 | default as resource, 103 | Resource, 104 | ResourceOptions, 105 | } from './reactivity/resource'; 106 | export { 107 | template, 108 | templateRef, 109 | } from './reactivity/template'; 110 | export { 111 | Effect, 112 | Cleanup, 113 | ErrorCapture, 114 | Ref, 115 | } from './reactivity/types'; 116 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/reactive-weak-set.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * MIT License 4 | * 5 | * Copyright (c) 2021 Alexis Munsayac 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * 24 | * 25 | * @author Alexis Munsayac 26 | * @copyright Alexis Munsayac 2021 27 | */ 28 | import { 29 | createReactiveAtom, 30 | destroyReactiveAtom, 31 | notifyReactiveAtom, 32 | onCleanup, 33 | TRACKING, 34 | } from './core'; 35 | import { 36 | createReactiveWeakKeys, 37 | notifyReactiveWeakKeys, 38 | ReactiveWeakKeys, 39 | trackReactiveWeakKeys, 40 | } from './nodes/reactive-weak-keys'; 41 | import { registerTrackable } from './trackable'; 42 | 43 | export default class ReactiveWeakSet implements WeakSet { 44 | private atom = createReactiveAtom(); 45 | 46 | private collection?: ReactiveWeakKeys; 47 | 48 | private source: WeakSet; 49 | 50 | constructor(source: WeakSet) { 51 | this.source = source; 52 | 53 | onCleanup(() => { 54 | destroyReactiveAtom(this.atom); 55 | }); 56 | 57 | registerTrackable(this.atom, this); 58 | } 59 | 60 | delete(value: V): boolean { 61 | const result = this.source.delete(value); 62 | if (result) { 63 | if (this.collection) { 64 | notifyReactiveWeakKeys(this.collection, value, true); 65 | } 66 | notifyReactiveAtom(this.atom); 67 | } 68 | return result; 69 | } 70 | 71 | get [Symbol.toStringTag](): string { 72 | return this.source[Symbol.toStringTag]; 73 | } 74 | 75 | add(value: V): this { 76 | const shouldNotify = !this.source.has(value); 77 | this.source.add(value); 78 | if (shouldNotify) { 79 | if (this.collection) { 80 | notifyReactiveWeakKeys(this.collection, value); 81 | } 82 | notifyReactiveAtom(this.atom); 83 | } 84 | return this; 85 | } 86 | 87 | has(value: V): boolean { 88 | if (TRACKING) { 89 | if (!this.collection) { 90 | this.collection = createReactiveWeakKeys(); 91 | } 92 | trackReactiveWeakKeys(this.collection, value); 93 | } 94 | return this.source.has(value); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /packages/react-compostate/src/useCompostateSetup.tsx: -------------------------------------------------------------------------------- 1 | import { useDebugValue, useEffect, useRef } from 'react'; 2 | import { 3 | syncEffect, 4 | reactive, 5 | untrack, 6 | } from 'compostate'; 7 | import { 8 | useConstant, 9 | useReactiveRef, 10 | } from '@lyonph/react-hooks'; 11 | import { 12 | createCompositionContext, 13 | getCompositionContext, 14 | runCompositionContext, 15 | } from './composition'; 16 | 17 | function createPropObject>( 18 | props: Props, 19 | ): Props { 20 | return reactive({ 21 | ...props, 22 | }); 23 | } 24 | 25 | export type CompostateSetup, T> = ( 26 | (props: Props) => () => T 27 | ); 28 | 29 | export default function useCompostateSetup, T>( 30 | setup: CompostateSetup, 31 | props: Props, 32 | ): T { 33 | const currentState = useConstant(() => { 34 | const propObject = createPropObject(props); 35 | const { context, render, lifecycle } = createCompositionContext(() => { 36 | let result: (() => T) | undefined; 37 | 38 | const lc = untrack(() => ( 39 | syncEffect(() => { 40 | result = setup(propObject); 41 | }) 42 | )); 43 | 44 | return { 45 | render: result, 46 | context: getCompositionContext(), 47 | lifecycle: lc, 48 | }; 49 | }); 50 | 51 | if (typeof render !== 'function') { 52 | throw new Error(` 53 | render is not a function. This maybe because the setup effect did not run 54 | or the setup returned a value that's not a function. 55 | `); 56 | } 57 | 58 | return { 59 | propObject, 60 | context, 61 | render, 62 | lifecycle, 63 | }; 64 | }); 65 | 66 | const result = useReactiveRef(() => currentState.render()); 67 | 68 | useEffect(() => currentState.lifecycle, [currentState]); 69 | 70 | useEffect(() => ( 71 | untrack(() => ( 72 | syncEffect(() => { 73 | result.current = currentState.render(); 74 | }) 75 | )) 76 | ), [result, currentState]); 77 | 78 | useEffect(() => ( 79 | untrack(() => ( 80 | syncEffect(() => { 81 | runCompositionContext( 82 | currentState.context, 83 | 'effect', 84 | ); 85 | }) 86 | )) 87 | ), [currentState]); 88 | 89 | useEffect(() => { 90 | runCompositionContext( 91 | currentState.context, 92 | 'mounted', 93 | ); 94 | return () => { 95 | runCompositionContext( 96 | currentState.context, 97 | 'unmounted', 98 | ); 99 | }; 100 | }, [currentState]); 101 | 102 | const initialMount = useRef(true); 103 | 104 | useEffect(() => { 105 | if (initialMount.current) { 106 | initialMount.current = false; 107 | } else { 108 | runCompositionContext( 109 | currentState.context, 110 | 'updated', 111 | ); 112 | } 113 | }); 114 | 115 | useEffect(() => { 116 | // eslint-disable-next-line no-restricted-syntax 117 | for (const [key, value] of Object.entries(props)) { 118 | currentState.propObject[key as keyof Props] = value; 119 | } 120 | }, [props, currentState]); 121 | 122 | useDebugValue(result.current); 123 | 124 | return result.current; 125 | } 126 | -------------------------------------------------------------------------------- /packages/compostate/src/linked-work.ts: -------------------------------------------------------------------------------- 1 | export interface LinkedWork { 2 | isSubscriber: boolean; 3 | tag: number; 4 | id: number; 5 | alive: boolean; 6 | links?: LinkedWork | Set; 7 | } 8 | 9 | let RUNNER: (work: LinkedWork) => void; 10 | 11 | export function setRunner(work: (work: LinkedWork) => void): void { 12 | RUNNER = work; 13 | } 14 | 15 | let STATE = 0; 16 | 17 | export function createLinkedWork( 18 | isSubscriber: boolean, 19 | tag: number, 20 | ): LinkedWork { 21 | return { 22 | isSubscriber, 23 | tag, 24 | id: STATE++, 25 | alive: true, 26 | }; 27 | } 28 | 29 | function registerLink( 30 | left: LinkedWork, 31 | right: LinkedWork, 32 | ): void { 33 | if (!left.links) { 34 | left.links = right; 35 | } else { 36 | let currentLinks = left.links; 37 | if (!(currentLinks instanceof Set)) { 38 | currentLinks = new Set([currentLinks]); 39 | left.links = currentLinks; 40 | } 41 | currentLinks.add(right); 42 | } 43 | } 44 | 45 | export function publisherLinkSubscriber( 46 | publisher: LinkedWork, 47 | subscriber: LinkedWork, 48 | ): void { 49 | if (publisher.alive && subscriber.alive) { 50 | registerLink(publisher, subscriber); 51 | registerLink(subscriber, publisher); 52 | } 53 | } 54 | 55 | export function enqueueSubscriberWork( 56 | target: LinkedWork, 57 | queue: Set, 58 | ): void { 59 | // Sets are internally ordered, so we can emulate 60 | // a simple queue where we move the node to the end 61 | // of the order 62 | // Currently this is the fastest and cheapest 63 | // non-linked list operation we can do 64 | queue.delete(target); 65 | queue.add(target); 66 | } 67 | 68 | export function evaluateSubscriberWork( 69 | target: LinkedWork, 70 | ): void { 71 | RUNNER(target); 72 | } 73 | 74 | export function enqueuePublisherWork( 75 | target: LinkedWork, 76 | queue: Set, 77 | ): void { 78 | if (target.links) { 79 | if (target.links instanceof Set) { 80 | for (const item of target.links.keys()) { 81 | enqueueSubscriberWork(item, queue); 82 | } 83 | } else { 84 | enqueueSubscriberWork(target.links, queue); 85 | } 86 | } 87 | } 88 | 89 | export function evaluatePublisherWork(target: LinkedWork): void { 90 | if (target.links) { 91 | if (target.links instanceof Set) { 92 | for (const item of target.links.keys()) { 93 | RUNNER(item); 94 | } 95 | } else { 96 | RUNNER(target.links); 97 | } 98 | } 99 | } 100 | 101 | export function unlinkLinkedWorkPublishers(target: LinkedWork): void { 102 | if (target.links) { 103 | if (target.links instanceof Set) { 104 | for (const item of target.links.keys()) { 105 | if (item.links instanceof Set) { 106 | item.links.delete(target); 107 | } else { 108 | item.links = undefined; 109 | } 110 | } 111 | target.links.clear(); 112 | } 113 | target.links = undefined; 114 | } 115 | } 116 | 117 | export function destroyLinkedWork(target: LinkedWork): void { 118 | if (target.alive) { 119 | target.alive = false; 120 | if (target.isSubscriber) { 121 | unlinkLinkedWorkPublishers(target); 122 | } 123 | target.links = undefined; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /packages/preact-compostate/src/useCompostateSetup.tsx: -------------------------------------------------------------------------------- 1 | import { useDebugValue, useEffect, useRef } from 'preact/hooks'; 2 | import { 3 | syncEffect, 4 | reactive, 5 | untrack, 6 | } from 'compostate'; 7 | import { 8 | useConstant, 9 | useReactiveRef, 10 | } from '@lyonph/preact-hooks'; 11 | import { 12 | createCompositionContext, 13 | getCompositionContext, 14 | runCompositionContext, 15 | } from './composition'; 16 | 17 | function createPropObject>( 18 | props: Props, 19 | ): Props { 20 | return reactive({ 21 | ...props, 22 | }); 23 | } 24 | 25 | export type CompostateSetup, T> = ( 26 | (props: Props) => () => T 27 | ); 28 | 29 | export default function useCompostateSetup, T>( 30 | setup: CompostateSetup, 31 | props: Props, 32 | ): T { 33 | const currentState = useConstant(() => { 34 | const propObject = createPropObject(props); 35 | const { context, render, lifecycle } = createCompositionContext(() => { 36 | let result: (() => T) | undefined; 37 | 38 | const lc = untrack(() => ( 39 | syncEffect(() => { 40 | result = setup(propObject); 41 | }) 42 | )); 43 | 44 | return { 45 | render: result, 46 | context: getCompositionContext(), 47 | lifecycle: lc, 48 | }; 49 | }); 50 | 51 | if (typeof render !== 'function') { 52 | throw new Error(` 53 | render is not a function. This maybe because the setup effect did not run 54 | or the setup returned a value that's not a function. 55 | `); 56 | } 57 | 58 | return { 59 | propObject, 60 | context, 61 | render, 62 | lifecycle, 63 | }; 64 | }); 65 | 66 | const result = useReactiveRef(() => currentState.render()); 67 | 68 | useEffect(() => currentState.lifecycle, [currentState]); 69 | 70 | useEffect(() => ( 71 | untrack(() => ( 72 | syncEffect(() => { 73 | result.current = currentState.render(); 74 | }) 75 | )) 76 | ), [result, currentState]); 77 | 78 | useEffect(() => ( 79 | untrack(() => ( 80 | syncEffect(() => { 81 | runCompositionContext( 82 | currentState.context, 83 | 'effect', 84 | ); 85 | }) 86 | )) 87 | ), [currentState]); 88 | 89 | useEffect(() => { 90 | runCompositionContext( 91 | currentState.context, 92 | 'mounted', 93 | ); 94 | return () => { 95 | runCompositionContext( 96 | currentState.context, 97 | 'unmounted', 98 | ); 99 | }; 100 | }, [currentState]); 101 | 102 | const initialMount = useRef(true); 103 | 104 | useEffect(() => { 105 | if (initialMount.current) { 106 | initialMount.current = false; 107 | } else { 108 | runCompositionContext( 109 | currentState.context, 110 | 'updated', 111 | ); 112 | } 113 | }); 114 | 115 | useEffect(() => { 116 | // eslint-disable-next-line no-restricted-syntax 117 | for (const [key, value] of Object.entries(props)) { 118 | currentState.propObject[key as keyof Props] = value; 119 | } 120 | }, [props, currentState]); 121 | 122 | useDebugValue(result.current); 123 | 124 | return result.current; 125 | } 126 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/reactive-weak-map.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * MIT License 4 | * 5 | * Copyright (c) 2021 Alexis Munsayac 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * 24 | * 25 | * @author Alexis Munsayac 26 | * @copyright Alexis Munsayac 2021 27 | */ 28 | import { 29 | createReactiveAtom, 30 | destroyReactiveAtom, 31 | notifyReactiveAtom, 32 | onCleanup, 33 | TRACKING, 34 | } from './core'; 35 | import { 36 | createReactiveWeakKeys, 37 | notifyReactiveWeakKeys, 38 | ReactiveWeakKeys, 39 | trackReactiveWeakKeys, 40 | } from './nodes/reactive-weak-keys'; 41 | import { registerTrackable } from './trackable'; 42 | 43 | // eslint-disable-next-line @typescript-eslint/ban-types 44 | export default class ReactiveWeakMap implements WeakMap { 45 | private source: WeakMap; 46 | 47 | private atom = createReactiveAtom(); 48 | 49 | private collection?: ReactiveWeakKeys; 50 | 51 | constructor(source: WeakMap) { 52 | this.source = source; 53 | 54 | onCleanup(() => { 55 | destroyReactiveAtom(this.atom); 56 | }); 57 | 58 | registerTrackable(this.atom, this); 59 | } 60 | 61 | delete(key: K): boolean { 62 | const result = this.source.delete(key); 63 | if (result) { 64 | if (this.collection) { 65 | notifyReactiveWeakKeys(this.collection, key, true); 66 | } 67 | notifyReactiveAtom(this.atom); 68 | } 69 | return result; 70 | } 71 | 72 | get [Symbol.toStringTag](): string { 73 | return this.source[Symbol.toStringTag]; 74 | } 75 | 76 | get(key: K): V | undefined { 77 | if (TRACKING) { 78 | if (!this.collection) { 79 | this.collection = createReactiveWeakKeys(); 80 | } 81 | trackReactiveWeakKeys(this.collection, key); 82 | } 83 | return this.source.get(key); 84 | } 85 | 86 | set(key: K, value: V): this { 87 | const current = this.source.get(key); 88 | if (!Object.is(current, value)) { 89 | this.source.set(key, value); 90 | if (this.collection) { 91 | notifyReactiveWeakKeys(this.collection, key); 92 | } 93 | notifyReactiveAtom(this.atom); 94 | } 95 | return this; 96 | } 97 | 98 | has(key: K): boolean { 99 | if (TRACKING) { 100 | if (!this.collection) { 101 | this.collection = createReactiveWeakKeys(); 102 | } 103 | trackReactiveWeakKeys(this.collection, key); 104 | } 105 | return this.source.has(key); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/refs.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createReactiveAtom, 3 | watch, 4 | notifyReactiveAtom, 5 | TRACKING, 6 | trackReactiveAtom, 7 | ReactiveAtom, 8 | untrack, 9 | captureReactiveAtomForCleanup, 10 | syncEffect, 11 | captured, 12 | effect, 13 | } from './core'; 14 | import { 15 | readonly, 16 | } from './readonly'; 17 | import { 18 | TRACKABLE, 19 | WithTrackable, 20 | } from './trackable'; 21 | import { 22 | Ref, 23 | } from './types'; 24 | 25 | const { is } = Object; 26 | 27 | export const REF = Symbol('COMPOSTATE_REF'); 28 | 29 | export type WithRef = { 30 | [REF]: boolean; 31 | }; 32 | 33 | export function isRef(object: any): object is Ref { 34 | return object && typeof object === 'object' && REF in object; 35 | } 36 | 37 | export function computedRef( 38 | compute: () => T, 39 | isEqual: (next: T, prev: T) => boolean = is, 40 | ): Readonly> { 41 | const instance = createReactiveAtom(); 42 | captureReactiveAtomForCleanup(instance); 43 | 44 | let value: T; 45 | let initial = true; 46 | let doSetup = true; 47 | 48 | const setup = captured(() => { 49 | syncEffect( 50 | watch(compute, (current) => { 51 | value = current; 52 | if (initial) { 53 | initial = false; 54 | } else { 55 | notifyReactiveAtom(instance); 56 | } 57 | }, isEqual), 58 | ); 59 | }); 60 | 61 | const node: Ref & WithRef & WithTrackable = readonly({ 62 | [REF]: true, 63 | [TRACKABLE]: instance, 64 | get value(): T { 65 | if (doSetup) { 66 | setup(); 67 | doSetup = false; 68 | } 69 | if (TRACKING) { 70 | trackReactiveAtom(instance); 71 | } 72 | return value; 73 | }, 74 | }); 75 | 76 | return node; 77 | } 78 | 79 | class RefNode implements WithRef { 80 | private val: T; 81 | 82 | private instance: ReactiveAtom; 83 | 84 | private isEqual: (next: T, prev: T) => boolean; 85 | 86 | [REF]: boolean; 87 | 88 | constructor( 89 | value: T, 90 | instance: ReactiveAtom, 91 | isEqual: (next: T, prev: T) => boolean, 92 | ) { 93 | this.val = value; 94 | this.instance = instance; 95 | this.isEqual = isEqual; 96 | this[REF] = true; 97 | } 98 | 99 | get value() { 100 | if (TRACKING) { 101 | trackReactiveAtom(this.instance); 102 | } 103 | return this.val; 104 | } 105 | 106 | set value(next: T) { 107 | if (!this.isEqual(next, this.val)) { 108 | this.val = next; 109 | notifyReactiveAtom(this.instance); 110 | } 111 | } 112 | } 113 | 114 | export function ref( 115 | value: T, 116 | isEqual: (next: T, prev: T) => boolean = is, 117 | ): Ref { 118 | const instance = createReactiveAtom(); 119 | captureReactiveAtomForCleanup(instance); 120 | return new RefNode(value, instance, isEqual); 121 | } 122 | 123 | export function deferredRef( 124 | callback: () => T, 125 | isEqual: (next: T, prev: T) => boolean = is, 126 | ): Readonly> { 127 | const instance = createReactiveAtom(); 128 | captureReactiveAtomForCleanup(instance); 129 | 130 | let value: T; 131 | 132 | const setup = captured(() => { 133 | effect(() => { 134 | const next = callback(); 135 | if (!isEqual(value, next)) { 136 | value = next; 137 | notifyReactiveAtom(instance); 138 | } 139 | }); 140 | }); 141 | 142 | let doSetup = true; 143 | 144 | const node: Ref & WithRef & WithTrackable = readonly({ 145 | [REF]: true, 146 | [TRACKABLE]: instance, 147 | get value(): T { 148 | if (doSetup) { 149 | value = untrack(callback); 150 | setup(); 151 | doSetup = false; 152 | } 153 | if (TRACKING) { 154 | trackReactiveAtom(instance); 155 | } 156 | return value; 157 | }, 158 | }); 159 | 160 | return node; 161 | } 162 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/reactive-object.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * MIT License 4 | * 5 | * Copyright (c) 2021 Alexis Munsayac 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * 24 | * 25 | * @author Alexis Munsayac 26 | * @copyright Alexis Munsayac 2021 27 | */ 28 | import { 29 | createReactiveAtom, 30 | destroyReactiveAtom, 31 | notifyReactiveAtom, 32 | onCleanup, 33 | TRACKING, 34 | } from './core'; 35 | import { 36 | createReactiveKeys, 37 | destroyReactiveKeys, 38 | notifyReactiveKeys, 39 | ReactiveKeys, 40 | trackReactiveKeys, 41 | } from './nodes/reactive-keys'; 42 | import { registerTrackable } from './trackable'; 43 | import { ReactiveObject } from './types'; 44 | 45 | class ReactiveObjectHandler { 46 | collection?: ReactiveKeys; 47 | 48 | atom = createReactiveAtom(); 49 | 50 | destroy() { 51 | if (this.collection) { 52 | destroyReactiveKeys(this.collection); 53 | } 54 | destroyReactiveAtom(this.atom); 55 | } 56 | 57 | get(target: T, key: string | symbol, receiver: any) { 58 | if (TRACKING) { 59 | if (!this.collection) { 60 | this.collection = createReactiveKeys(); 61 | } 62 | trackReactiveKeys(this.collection, key); 63 | } 64 | return Reflect.get(target, key, receiver); 65 | } 66 | 67 | has(target: T, key: string | symbol) { 68 | if (TRACKING) { 69 | if (!this.collection) { 70 | this.collection = createReactiveKeys(); 71 | } 72 | trackReactiveKeys(this.collection, key); 73 | } 74 | return Reflect.has(target, key); 75 | } 76 | 77 | deleteProperty(target: T, key: string | symbol) { 78 | const deleted = Reflect.deleteProperty(target, key); 79 | if (deleted) { 80 | if (this.collection) { 81 | notifyReactiveKeys(this.collection, key, true); 82 | } 83 | notifyReactiveAtom(this.atom); 84 | } 85 | return deleted; 86 | } 87 | 88 | set(target: T, key: string | symbol, value: any, receiver: any) { 89 | const current = Reflect.get(target, key, receiver); 90 | 91 | const result = Reflect.set(target, key, value, receiver); 92 | 93 | if (result && !Object.is(current, value)) { 94 | if (this.collection) { 95 | notifyReactiveKeys(this.collection, key); 96 | } 97 | notifyReactiveAtom(this.atom); 98 | } 99 | 100 | return result; 101 | } 102 | } 103 | 104 | export default function createReactiveObject( 105 | source: T, 106 | ): T { 107 | const handler = new ReactiveObjectHandler(); 108 | 109 | onCleanup(() => { 110 | handler.destroy(); 111 | }); 112 | 113 | const proxy = new Proxy(source, handler); 114 | 115 | registerTrackable(handler.atom, proxy); 116 | 117 | return proxy as T; 118 | } 119 | -------------------------------------------------------------------------------- /packages/compostate-element/src/define.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createRoot, 3 | effect, 4 | reactive, 5 | syncEffect, 6 | } from 'compostate'; 7 | import { 8 | createDOMContext, 9 | DOMContext, 10 | getDOMContext, 11 | runContext, 12 | } from './composition'; 13 | import { render } from './renderer'; 14 | import kebabify from './utils/kebabify'; 15 | 16 | export type PropObject = { 17 | [key in Props]?: string | undefined; 18 | }; 19 | 20 | export type ComponentRender = ( 21 | () => RenderResult 22 | ); 23 | export type ComponentSetup = ( 24 | (props: PropObject) => ComponentRender 25 | ); 26 | 27 | export interface Component { 28 | name: string; 29 | props?: Props[]; 30 | setup: ComponentSetup; 31 | } 32 | 33 | export default function define( 34 | options: Component | ComponentSetup, 35 | ): void { 36 | if (typeof options === 'function') { 37 | define({ 38 | name: kebabify(options.name), 39 | setup: options, 40 | }); 41 | return; 42 | } 43 | 44 | const { props, name } = options; 45 | 46 | const currentProps = props ?? []; 47 | 48 | customElements.define(kebabify(name), class extends HTMLElement { 49 | static get observedAttributes(): string[] { 50 | return currentProps; 51 | } 52 | 53 | private context?: DOMContext; 54 | 55 | private props: PropObject; 56 | 57 | private root: ShadowRoot; 58 | 59 | private lifecycle?: () => void; 60 | 61 | constructor() { 62 | super(); 63 | 64 | // Create a shallow object of state from 65 | // the defined properties. 66 | this.props = createRoot(() => reactive({})); 67 | 68 | this.root = this.attachShadow({ 69 | mode: 'closed', 70 | }); 71 | } 72 | 73 | connectedCallback() { 74 | // Isolate so that the lifecycle of 75 | // this effect is not synchronously 76 | // tracked by a parent effect. 77 | this.lifecycle = createRoot(() => ( 78 | createDOMContext(() => ( 79 | syncEffect(() => { 80 | // Create a context for composition 81 | this.context = getDOMContext(); 82 | const result = options.setup(this.props); 83 | 84 | let mounted = false; 85 | 86 | // The effect is separated so that 87 | // observed values in the render function 88 | // do not update nor re-evaluate the setup 89 | // function 90 | effect(() => { 91 | const nodes = result(); 92 | 93 | // Render the result to the root 94 | render(this.root, nodes); 95 | 96 | // If the element has been mounted before 97 | // the re-render is an update call, we 98 | // run the onUpdated hooks. 99 | if (mounted && this.context) { 100 | runContext(this.context, 'updated'); 101 | } 102 | 103 | // Mark the element as mounted. 104 | mounted = true; 105 | }); 106 | }) 107 | )) 108 | )); 109 | 110 | // Run onConnected hooks 111 | if (this.context) { 112 | runContext(this.context, 'connected'); 113 | } 114 | } 115 | 116 | disconnectedCallback() { 117 | // Run onDisconnected hooks 118 | if (this.context) { 119 | runContext(this.context, 'disconnected'); 120 | 121 | // Remove context 122 | this.context = undefined; 123 | } 124 | 125 | // If there's a lifecycle, make sure to clean it 126 | if (this.lifecycle) { 127 | this.lifecycle(); 128 | this.lifecycle = undefined; 129 | } 130 | } 131 | 132 | adoptedCallback() { 133 | if (this.context) { 134 | runContext(this.context, 'adopted'); 135 | } 136 | } 137 | 138 | attributeChangedCallback(attribute: Props, _: string, newValue: string) { 139 | this.props[attribute] = newValue; 140 | } 141 | }); 142 | } 143 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/reactive-set.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * MIT License 4 | * 5 | * Copyright (c) 2021 Alexis Munsayac 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * 24 | * 25 | * @author Alexis Munsayac 26 | * @copyright Alexis Munsayac 2021 27 | */ 28 | import { 29 | createReactiveAtom, 30 | destroyReactiveAtom, 31 | notifyReactiveAtom, 32 | onCleanup, 33 | TRACKING, 34 | } from './core'; 35 | import { 36 | createReactiveKeys, 37 | destroyReactiveKeys, 38 | notifyAllReactiveKeys, 39 | notifyReactiveKeys, 40 | ReactiveKeys, 41 | trackReactiveKeys, 42 | } from './nodes/reactive-keys'; 43 | import { registerTrackable } from './trackable'; 44 | 45 | export default class ReactiveSet implements Set { 46 | private collection?: ReactiveKeys; 47 | 48 | private atom = createReactiveAtom(); 49 | 50 | private source: Set; 51 | 52 | constructor(source: Set) { 53 | this.source = source; 54 | 55 | onCleanup(() => { 56 | if (this.collection) { 57 | destroyReactiveKeys(this.collection); 58 | } 59 | destroyReactiveAtom(this.atom); 60 | }); 61 | 62 | registerTrackable(this.atom, this); 63 | } 64 | 65 | clear(): void { 66 | this.source.clear(); 67 | if (this.collection) { 68 | notifyAllReactiveKeys(this.collection, true); 69 | } 70 | notifyReactiveAtom(this.atom); 71 | } 72 | 73 | delete(value: V): boolean { 74 | const result = this.source.delete(value); 75 | if (result) { 76 | if (this.collection) { 77 | notifyReactiveKeys(this.collection, value, true); 78 | } 79 | notifyReactiveAtom(this.atom); 80 | } 81 | return result; 82 | } 83 | 84 | forEach( 85 | callbackfn: (value: V, key: V, map: Set) => void, 86 | thisArg?: ReactiveSet, 87 | ): void { 88 | this.forEach(callbackfn, thisArg); 89 | } 90 | 91 | get size(): number { 92 | return this.source.size; 93 | } 94 | 95 | entries(): IterableIterator<[V, V]> { 96 | return this.source.entries(); 97 | } 98 | 99 | keys(): IterableIterator { 100 | return this.source.keys(); 101 | } 102 | 103 | values(): IterableIterator { 104 | return this.source.values(); 105 | } 106 | 107 | [Symbol.iterator](): IterableIterator { 108 | return this.source[Symbol.iterator](); 109 | } 110 | 111 | get [Symbol.toStringTag](): string { 112 | return this.source[Symbol.toStringTag]; 113 | } 114 | 115 | add(value: V): this { 116 | const shouldNotify = !this.source.has(value); 117 | this.source.add(value); 118 | if (shouldNotify) { 119 | if (this.collection) { 120 | notifyReactiveKeys(this.collection, value); 121 | } 122 | notifyReactiveAtom(this.atom); 123 | } 124 | return this; 125 | } 126 | 127 | has(value: V): boolean { 128 | if (TRACKING) { 129 | if (!this.collection) { 130 | this.collection = createReactiveKeys(); 131 | } 132 | trackReactiveKeys(this.collection, value); 133 | } 134 | return this.source.has(value); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/reactive-map.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * MIT License 4 | * 5 | * Copyright (c) 2021 Alexis Munsayac 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * 24 | * 25 | * @author Alexis Munsayac 26 | * @copyright Alexis Munsayac 2021 27 | */ 28 | import { 29 | createReactiveAtom, 30 | notifyReactiveAtom, 31 | destroyReactiveAtom, 32 | onCleanup, 33 | TRACKING, 34 | } from './core'; 35 | import { 36 | createReactiveKeys, 37 | destroyReactiveKeys, 38 | notifyAllReactiveKeys, 39 | notifyReactiveKeys, 40 | ReactiveKeys, 41 | trackReactiveKeys, 42 | } from './nodes/reactive-keys'; 43 | import { registerTrackable } from './trackable'; 44 | 45 | export default class ReactiveMap implements Map { 46 | private source: Map; 47 | 48 | private atom = createReactiveAtom(); 49 | 50 | private collection?: ReactiveKeys; 51 | 52 | constructor(source: Map) { 53 | this.source = source; 54 | 55 | onCleanup(() => { 56 | if (this.collection) { 57 | destroyReactiveKeys(this.collection); 58 | } 59 | destroyReactiveAtom(this.atom); 60 | }); 61 | 62 | registerTrackable(this.atom, this); 63 | } 64 | 65 | clear(): void { 66 | this.source.clear(); 67 | if (this.collection) { 68 | notifyAllReactiveKeys(this.collection, true); 69 | } 70 | notifyReactiveAtom(this.atom); 71 | } 72 | 73 | delete(key: K): boolean { 74 | const result = this.source.delete(key); 75 | if (result) { 76 | if (this.collection) { 77 | notifyReactiveKeys(this.collection, key, true); 78 | } 79 | notifyReactiveAtom(this.atom); 80 | } 81 | return result; 82 | } 83 | 84 | forEach( 85 | callbackfn: (value: V, key: K, map: Map) => void, 86 | thisArg?: ReactiveMap, 87 | ): void { 88 | this.forEach(callbackfn, thisArg); 89 | } 90 | 91 | get size(): number { 92 | return this.source.size; 93 | } 94 | 95 | entries(): IterableIterator<[K, V]> { 96 | return this.source.entries(); 97 | } 98 | 99 | keys(): IterableIterator { 100 | return this.source.keys(); 101 | } 102 | 103 | values(): IterableIterator { 104 | return this.source.values(); 105 | } 106 | 107 | [Symbol.iterator](): IterableIterator<[K, V]> { 108 | return this.source[Symbol.iterator](); 109 | } 110 | 111 | get [Symbol.toStringTag](): string { 112 | return this.source[Symbol.toStringTag]; 113 | } 114 | 115 | get(key: K): V | undefined { 116 | if (TRACKING) { 117 | if (!this.collection) { 118 | this.collection = createReactiveKeys(); 119 | } 120 | trackReactiveKeys(this.collection, key); 121 | } 122 | return this.source.get(key); 123 | } 124 | 125 | set(key: K, value: V): this { 126 | const current = this.source.get(key); 127 | if (!Object.is(current, value)) { 128 | this.source.set(key, value); 129 | if (this.collection) { 130 | notifyReactiveKeys(this.collection, key); 131 | } 132 | notifyReactiveAtom(this.atom); 133 | } 134 | return this; 135 | } 136 | 137 | has(key: K): boolean { 138 | if (TRACKING) { 139 | if (!this.collection) { 140 | this.collection = createReactiveKeys(); 141 | } 142 | trackReactiveKeys(this.collection, key); 143 | } 144 | return this.source.has(key); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /packages/compostate/src/scheduler.ts: -------------------------------------------------------------------------------- 1 | // From: https://github.com/solidjs/solid/blob/main/packages/solid/src/reactive/scheduler.ts 2 | 3 | // Basic port modification of Reacts Scheduler: https://github.com/facebook/react/tree/master/packages/scheduler 4 | export interface Task { 5 | id: number; 6 | fn: ((didTimeout: boolean) => void) | null; 7 | startTime: number; 8 | expirationTime: number; 9 | } 10 | 11 | // experimental new feature proposal stuff 12 | type NavigatorScheduling = Navigator & { 13 | scheduling: { isInputPending?: () => boolean }; 14 | }; 15 | 16 | let taskIdCounter = 1; 17 | let isCallbackScheduled = false; 18 | let isPerformingWork = false; 19 | const taskQueue: Task[] = []; 20 | let currentTask: Task | null = null; 21 | let shouldYieldToHost: (() => boolean) | null = null; 22 | const yieldInterval = 5; 23 | let deadline = 0; 24 | const maxYieldInterval = 300; 25 | let scheduleCallback: (() => void) | null = null; 26 | let scheduledCallback: ((hasTimeRemaining: boolean, initialTime: number) => boolean) | null = null; 27 | 28 | const maxSigned31BitInt = 1073741823; 29 | /* istanbul ignore next */ 30 | function setupScheduler() { 31 | const channel = new MessageChannel(); 32 | const port = channel.port2; 33 | scheduleCallback = () => port.postMessage(null); 34 | channel.port1.onmessage = () => { 35 | if (scheduledCallback !== null) { 36 | const currentTime = performance.now(); 37 | deadline = currentTime + yieldInterval; 38 | const hasTimeRemaining = true; 39 | try { 40 | const hasMoreWork = scheduledCallback(hasTimeRemaining, currentTime); 41 | if (!hasMoreWork) { 42 | scheduledCallback = null; 43 | } else port.postMessage(null); 44 | } catch (error) { 45 | // If a scheduler task throws, exit the current browser task so the 46 | // error can be observed. 47 | port.postMessage(null); 48 | throw error; 49 | } 50 | } 51 | }; 52 | 53 | if ( 54 | typeof navigator !== 'undefined' 55 | && (navigator as NavigatorScheduling).scheduling 56 | && (navigator as NavigatorScheduling).scheduling.isInputPending 57 | ) { 58 | const { scheduling } = navigator as NavigatorScheduling; 59 | shouldYieldToHost = () => { 60 | const currentTime = performance.now(); 61 | if (currentTime >= deadline) { 62 | // There's no time left. We may want to yield control of the main 63 | // thread, so the browser can perform high priority tasks. The main ones 64 | // are painting and user input. If there's a pending paint or a pending 65 | // input, then we should yield. But if there's neither, then we can 66 | // yield less often while remaining responsive. We'll eventually yield 67 | // regardless, since there could be a pending paint that wasn't 68 | // accompanied by a call to `requestPaint`, or other main thread tasks 69 | // like network events. 70 | if (scheduling.isInputPending!()) { 71 | return true; 72 | } 73 | // There's no pending input. Only yield if we've reached the max 74 | // yield interval. 75 | return currentTime >= maxYieldInterval; 76 | } 77 | // There's still time left in the frame. 78 | return false; 79 | }; 80 | } else { 81 | // `isInputPending` is not available. Since we have no way of knowing if 82 | // there's pending input, always yield at the end of the frame. 83 | shouldYieldToHost = () => performance.now() >= deadline; 84 | } 85 | } 86 | 87 | function enqueue(queue: Task[], task: Task) { 88 | function findIndex() { 89 | let m = 0; 90 | let n = queue.length - 1; 91 | 92 | while (m <= n) { 93 | const k = (n + m) >> 1; 94 | const cmp = task.expirationTime - queue[k].expirationTime; 95 | if (cmp > 0) m = k + 1; 96 | else if (cmp < 0) n = k - 1; 97 | else return k; 98 | } 99 | return m; 100 | } 101 | queue.splice(findIndex(), 0, task); 102 | } 103 | 104 | function workLoop(hasTimeRemaining: boolean, initialTime: number) { 105 | let currentTime = initialTime; 106 | currentTask = taskQueue[0] || null; 107 | while (currentTask !== null) { 108 | if (currentTask.expirationTime > currentTime && (!hasTimeRemaining || shouldYieldToHost!())) { 109 | // This currentTask hasn't expired, and we've reached the deadline. 110 | break; 111 | } 112 | const callback = currentTask.fn; 113 | if (callback !== null) { 114 | currentTask.fn = null; 115 | const didUserCallbackTimeout = currentTask.expirationTime <= currentTime; 116 | callback(didUserCallbackTimeout); 117 | currentTime = performance.now(); 118 | if (currentTask === taskQueue[0]) { 119 | taskQueue.shift(); 120 | } 121 | } else taskQueue.shift(); 122 | currentTask = taskQueue[0] || null; 123 | } 124 | // Return whether there's additional work 125 | return currentTask !== null; 126 | } 127 | 128 | function flushWork(hasTimeRemaining: boolean, initialTime: number) { 129 | // We'll need a host callback the next time work is scheduled. 130 | isCallbackScheduled = false; 131 | isPerformingWork = true; 132 | try { 133 | return workLoop(hasTimeRemaining, initialTime); 134 | } finally { 135 | currentTask = null; 136 | isPerformingWork = false; 137 | } 138 | } 139 | 140 | export function requestCallback(fn: () => void, options?: { timeout?: number }): Task { 141 | if (!scheduleCallback) setupScheduler(); 142 | const startTime = performance.now(); 143 | let timeout = maxSigned31BitInt; 144 | 145 | if (options && options.timeout) timeout = options.timeout; 146 | 147 | const newTask: Task = { 148 | id: taskIdCounter++, 149 | fn, 150 | startTime, 151 | expirationTime: startTime + timeout, 152 | }; 153 | 154 | enqueue(taskQueue, newTask); 155 | if (!isCallbackScheduled && !isPerformingWork) { 156 | isCallbackScheduled = true; 157 | scheduledCallback = flushWork; 158 | if (scheduleCallback) { 159 | scheduleCallback(); 160 | } 161 | } 162 | 163 | return newTask; 164 | } 165 | 166 | export function cancelCallback(task: Task): void { 167 | task.fn = null; 168 | } 169 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/array.ts: -------------------------------------------------------------------------------- 1 | import { 2 | batchCleanup, 3 | createRoot, 4 | onCleanup, 5 | untrack, 6 | } from './core'; 7 | import { ref } from './refs'; 8 | import { Cleanup, Ref } from './types'; 9 | 10 | function dispose(d: Cleanup[]) { 11 | for (let i = 0, len = d.length; i < len; i++) d[i](); 12 | } 13 | 14 | // From https://github.com/solidjs/solid/blob/main/packages/solid/src/reactive/array.ts 15 | 16 | interface Mapper { 17 | (v: T): U; 18 | (v: T, i: Ref): U; 19 | } 20 | 21 | export function map( 22 | list: () => T[], 23 | mapFn: Mapper, 24 | ): () => U[] { 25 | let items: T[] = []; 26 | let mapped: U[] = []; 27 | let disposers: (() => void)[] = []; 28 | let len = 0; 29 | let indexes: Ref[] = []; 30 | 31 | onCleanup(() => dispose(disposers)); 32 | 33 | return () => { 34 | const newItems = list(); 35 | let i: number; 36 | let j: number; 37 | 38 | function mapper() { 39 | let result: U | undefined; 40 | disposers[j] = batchCleanup(() => { 41 | if (mapFn.length === 1) { 42 | result = mapFn(newItems[j]); 43 | } else { 44 | const key = ref(j); 45 | indexes[j] = key; 46 | result = mapFn(newItems[j], key); 47 | } 48 | }); 49 | return result as U; 50 | } 51 | return untrack(() => { 52 | const newLen = newItems.length; 53 | let newIndices: Map; 54 | let newIndicesNext: number[]; 55 | let temp: U[]; 56 | let tempdisposers: Cleanup[]; 57 | let tempIndexes: Ref[]; 58 | let start: number; 59 | let end: number; 60 | let newEnd: number; 61 | let item: T; 62 | 63 | // fast path for empty arrays 64 | if (newLen === 0) { 65 | if (len !== 0) { 66 | dispose(disposers); 67 | disposers = []; 68 | items = []; 69 | mapped = []; 70 | len = 0; 71 | indexes = []; 72 | } 73 | } else if (len === 0) { 74 | // fast path for new create 75 | mapped = new Array(newLen); 76 | for (j = 0; j < newLen; j++) { 77 | items[j] = newItems[j]; 78 | mapped[j] = createRoot(mapper); 79 | } 80 | len = newLen; 81 | } else { 82 | temp = new Array(newLen); 83 | tempdisposers = new Array(newLen); 84 | tempIndexes = new Array>(newLen); 85 | 86 | // skip common prefix 87 | for ( 88 | start = 0, end = Math.min(len, newLen); 89 | start < end && items[start] === newItems[start]; 90 | start++ 91 | ); 92 | 93 | // common suffix 94 | for ( 95 | end = len - 1, newEnd = newLen - 1; 96 | end >= start && newEnd >= start && items[end] === newItems[newEnd]; 97 | end--, newEnd-- 98 | ) { 99 | temp[newEnd] = mapped[end]; 100 | tempdisposers[newEnd] = disposers[end]; 101 | tempIndexes[newEnd] = indexes[end]; 102 | } 103 | 104 | // 0) prepare a map of all indices in newItems, 105 | // scanning backwards so we encounter them in natural order 106 | newIndices = new Map(); 107 | newIndicesNext = new Array(newEnd + 1); 108 | for (j = newEnd; j >= start; j--) { 109 | item = newItems[j]; 110 | i = newIndices.get(item)!; 111 | newIndicesNext[j] = i === undefined ? -1 : i; 112 | newIndices.set(item, j); 113 | } 114 | // 1) step through all old items and see if they can be found 115 | // in the new set; if so, save them in a temp array and 116 | // mark them moved; if not, exit them 117 | for (i = start; i <= end; i++) { 118 | item = items[i]; 119 | j = newIndices.get(item)!; 120 | if (j !== undefined && j !== -1) { 121 | temp[j] = mapped[i]; 122 | tempdisposers[j] = disposers[i]; 123 | tempIndexes[j] = indexes[i]; 124 | j = newIndicesNext[j]; 125 | newIndices.set(item, j); 126 | } else disposers[i](); 127 | } 128 | // 2) set all the new values, pulling from the temp array if copied, 129 | // otherwise entering the new value 130 | for (j = start; j < newLen; j++) { 131 | if (j in temp) { 132 | mapped[j] = temp[j]; 133 | disposers[j] = tempdisposers[j]; 134 | const refIndex = tempIndexes[j]; 135 | if (refIndex) { 136 | indexes[j] = refIndex; 137 | indexes[j].value = j; 138 | } 139 | } else mapped[j] = createRoot(mapper); 140 | } 141 | // 3) in case the new set is shorter than the old, set the length of the mapped array 142 | mapped = mapped.slice(0, (len = newLen)); 143 | // 4) save a copy of the mapped items for the next update 144 | items = newItems.slice(0); 145 | } 146 | return mapped; 147 | }); 148 | }; 149 | } 150 | 151 | export function index( 152 | list: () => T[], 153 | mapFn: (v: Ref, i: number) => U, 154 | ): () => U[] { 155 | let items: T[] = []; 156 | let mapped: U[] = []; 157 | let disposers: (() => void)[] = []; 158 | let refs: Ref[] = []; 159 | let len = 0; 160 | let i: number; 161 | 162 | onCleanup(() => dispose(disposers)); 163 | return () => { 164 | const newItems = list(); 165 | 166 | function mapper() { 167 | let result: U | undefined; 168 | disposers[i] = batchCleanup(() => { 169 | const item = ref(newItems[i]); 170 | refs[i] = item; 171 | result = mapFn(item, i); 172 | }); 173 | return result as U; 174 | } 175 | 176 | return untrack(() => { 177 | if (newItems.length === 0) { 178 | if (len !== 0) { 179 | dispose(disposers); 180 | disposers = []; 181 | items = []; 182 | mapped = []; 183 | len = 0; 184 | refs = []; 185 | } 186 | return mapped; 187 | } 188 | 189 | for (i = 0; i < newItems.length; i++) { 190 | if (i < items.length && items[i] !== newItems[i]) { 191 | refs[i].value = newItems[i]; 192 | } else if (i >= items.length) { 193 | mapped[i] = createRoot(mapper); 194 | } 195 | } 196 | for (; i < items.length; i++) { 197 | disposers[i](); 198 | } 199 | len = newItems.length; 200 | disposers.length = len; 201 | refs.length = len; 202 | items = newItems.slice(0); 203 | mapped = mapped.slice(0, len); 204 | return mapped; 205 | }); 206 | }; 207 | } 208 | -------------------------------------------------------------------------------- /packages/compostate/test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | computed, 3 | effect, 4 | reactive, 5 | // effect, 6 | ref, 7 | untrack, 8 | } from '../src'; 9 | 10 | describe('ref', () => { 11 | it('should return an object with value property with the received value', () => { 12 | const expected = Date.now(); 13 | const state = ref(expected); 14 | expect(state.value).toBe(expected); 15 | }); 16 | }); 17 | describe('computed', () => { 18 | describe('with ref', () => { 19 | it('should receive value from derived ref', () => { 20 | const expected = Date.now(); 21 | const state = ref(expected); 22 | const derived = computed(() => state.value); 23 | expect(derived.value).toBe(expected); 24 | }); 25 | it('should recompute when tracked ref updates.', () => { 26 | const initial = 'Initial'; 27 | const expected = 'Expected'; 28 | const state = ref(initial); 29 | const derived = computed(() => state.value); 30 | state.value = expected; 31 | expect(derived.value).toBe(expected); 32 | }); 33 | }); 34 | describe('with reactive object', () => { 35 | it('should recompute when tracked object adds a key', () => { 36 | const initial = 'Initial'; 37 | const state = reactive<{ value?: string }>({}); 38 | const derived = computed(() => 'value' in state); 39 | state.value = initial; 40 | expect(derived.value).toBe(true); 41 | }); 42 | it('should recompute when tracked object adds a key', () => { 43 | const initial = 'Initial'; 44 | const state = reactive<{ value?: string }>({ value: initial }); 45 | const derived = computed(() => 'value' in state); 46 | delete state.value; 47 | delete state.value; 48 | expect(derived.value).toBe(false); 49 | }); 50 | }); 51 | describe('with reactive array', () => { 52 | it('should recompute when tracked reactive array updates.', () => { 53 | const initial = 'Initial'; 54 | const expected = 'Expected'; 55 | const state = reactive([initial]); 56 | const derived = computed(() => state[0]); 57 | state[0] = expected; 58 | expect(derived.value).toBe(expected); 59 | }); 60 | }); 61 | describe('with reactive Map', () => { 62 | it('should recompute when tracked Map adds a value.', () => { 63 | const expected = 'Expected'; 64 | const state = reactive(new Map()); 65 | const derived = computed(() => state.has('value')); 66 | state.set('value', expected); 67 | expect(derived.value).toBe(true); 68 | }); 69 | it('should recompute when tracked key updates.', () => { 70 | const initial = 'Initial'; 71 | const expected = 'Expected'; 72 | const state = reactive(new Map([['value', initial]])); 73 | const derived = computed(() => state.get('value')); 74 | state.set('value', expected); 75 | state.set('value', expected); 76 | expect(derived.value).toBe(expected); 77 | }); 78 | it('should recompute when tracked key is deleted.', () => { 79 | const initial = 'Initial'; 80 | const state = reactive(new Map([['value', initial]])); 81 | const derived = computed(() => state.get('value')); 82 | state.delete('value'); 83 | state.delete('value'); 84 | expect(derived.value).toBe(undefined); 85 | }); 86 | it('should recompute when tracked Map clears.', () => { 87 | const initial = 'Initial'; 88 | const state = reactive(new Map([['value', initial]])); 89 | const derived = computed(() => state.get('value')); 90 | state.clear(); 91 | expect(derived.value).toBe(undefined); 92 | }); 93 | }); 94 | describe('with reactive WeakMap', () => { 95 | const KEY = {}; 96 | it('should recompute when tracked adds a value.', () => { 97 | const expected = 'Expected'; 98 | const state = reactive(new WeakMap()); 99 | const derived = computed(() => state.has(KEY)); 100 | state.set(KEY, expected); 101 | expect(derived.value).toBe(true); 102 | }); 103 | it('should recompute when tracked key updates.', () => { 104 | const initial = 'Initial'; 105 | const expected = 'Expected'; 106 | const state = reactive(new WeakMap([[KEY, initial]])); 107 | const derived = computed(() => state.get(KEY)); 108 | state.set(KEY, expected); 109 | state.set(KEY, expected); 110 | expect(derived.value).toBe(expected); 111 | }); 112 | it('should recompute when tracked key is deleted.', () => { 113 | const initial = 'Initial'; 114 | const state = reactive(new WeakMap([[KEY, initial]])); 115 | const derived = computed(() => state.get(KEY)); 116 | state.delete(KEY); 117 | state.delete(KEY); 118 | expect(derived.value).toBe(undefined); 119 | }); 120 | }); 121 | describe('with reactive Set', () => { 122 | it('should recompute when tracked adds a value.', () => { 123 | const expected = 'Expected'; 124 | const state = reactive(new Set()); 125 | const derived = computed(() => state.has(expected)); 126 | state.add(expected); 127 | expect(derived.value).toBe(true); 128 | }); 129 | it('should recompute when tracked key is deleted.', () => { 130 | const initial = 'Initial'; 131 | const state = reactive(new Set([initial])); 132 | const derived = computed(() => state.has(initial)); 133 | state.delete(initial); 134 | state.delete(initial); 135 | expect(derived.value).toBe(false); 136 | }); 137 | it('should recompute when tracked Set clears.', () => { 138 | const initial = 'Initial'; 139 | const state = reactive(new Set([initial])); 140 | const derived = computed(() => state.has(initial)); 141 | state.clear(); 142 | expect(derived.value).toBe(false); 143 | }); 144 | }); 145 | describe('with reactive WeakSet', () => { 146 | const KEY = {}; 147 | it('should recompute when tracked adds a value.', () => { 148 | const state = reactive(new WeakSet()); 149 | const derived = computed(() => state.has(KEY)); 150 | state.add(KEY); 151 | expect(derived.value).toBe(true); 152 | }); 153 | it('should recompute when tracked key is deleted.', () => { 154 | const state = reactive(new WeakSet([KEY])); 155 | const derived = computed(() => state.has(KEY)); 156 | state.delete(KEY); 157 | state.delete(KEY); 158 | expect(derived.value).toBe(false); 159 | }); 160 | }); 161 | }); 162 | describe('effect', () => { 163 | it('should re-evalutate when tracked ref updates.', () => { 164 | const initial = 'Initial'; 165 | const expected = 'Expected'; 166 | const state = ref(initial); 167 | let updated = state.value; 168 | effect(() => { 169 | updated = state.value; 170 | }); 171 | state.value = expected; 172 | expect(updated).toBe(expected); 173 | }); 174 | it('should perform cleanup when tracked ref updates.', () => { 175 | const initial = 'Initial'; 176 | const expected = 'Expected'; 177 | const state = ref(initial); 178 | let updated = state.value; 179 | let cleaned = false; 180 | effect(() => { 181 | updated = state.value; 182 | return () => { 183 | cleaned = true; 184 | }; 185 | }); 186 | state.value = expected; 187 | expect(updated).toBe(expected); 188 | expect(cleaned).toBe(true); 189 | }); 190 | it('should stop tracking when stop is called.', () => { 191 | const count = ref(0); 192 | let derived = count.value; 193 | const stop = effect(() => { 194 | derived = count.value; 195 | }); 196 | count.value += 1; 197 | stop(); 198 | count.value += 1; 199 | expect(derived).toBe(1); 200 | }); 201 | it('should perform cleanup when stop is called.', () => { 202 | let cleaned = false; 203 | const stop = effect(() => () => { 204 | cleaned = true; 205 | }); 206 | stop(); 207 | expect(cleaned).toBe(true); 208 | }); 209 | }); 210 | describe('untrack', () => { 211 | describe('computed', () => { 212 | it('should not track wrapped ref', () => { 213 | const expected = 'Expected'; 214 | const update = 'Update'; 215 | const state = ref(expected); 216 | const derived = computed(() => untrack(() => state.value)); 217 | state.value = update; 218 | expect(derived.value).toBe(expected); 219 | }); 220 | }); 221 | describe('effect', () => { 222 | it('should not track wrapped ref', () => { 223 | const expected = 'Expected'; 224 | const update = 'Update'; 225 | const state = ref(expected); 226 | const rawDerived = ref(state.value); 227 | effect(() => { 228 | rawDerived.value = untrack(() => state.value); 229 | }); 230 | state.value = update; 231 | expect(rawDerived.value).toBe(expected); 232 | }); 233 | it('should not cleanup untracked child effect', () => { 234 | let cleaned = false; 235 | const stop = effect(() => { 236 | effect(() => { 237 | untrack(() => effect(() => () => { 238 | cleaned = true; 239 | })); 240 | }); 241 | }); 242 | stop(); 243 | expect(cleaned).toBe(false); 244 | }); 245 | }); 246 | }); 247 | describe('batch', () => { 248 | 249 | }); 250 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # compostate 2 | 3 | > Fine-grained reactivity library 4 | 5 | [![NPM](https://img.shields.io/npm/v/compostate.svg)](https://www.npmjs.com/package/compostate) [![JavaScript Style Guide](https://badgen.net/badge/code%20style/airbnb/ff5a5f?icon=airbnb)](https://github.com/airbnb/javascript) 6 | 7 | ## Install 8 | 9 | ```bash 10 | npm install --save compostate 11 | ``` 12 | 13 | ```bash 14 | yarn add compostate 15 | ``` 16 | 17 | ```bash 18 | pnpm add compostate 19 | ``` 20 | 21 | ## Concepts 22 | 23 | ### Signals and Atoms 24 | 25 | Signals and atoms are the main source of reactivity in `compostate`. They are akin to "subjects" or "observables" in the [Observer pattern](https://en.wikipedia.org/wiki/Observer_pattern). Signals and atoms holds values that can either be read or written. 26 | 27 | ```js 28 | import { signal, atom } from 'compostate'; 29 | 30 | // with signals 31 | const [count, setCount] = signal(0); 32 | // reading a signal 33 | console.log('Count', count()); 34 | // writing to a signal 35 | setCount(count() + 100); 36 | 37 | // with atoms 38 | const count = atom(0); 39 | // reading an atom 40 | console.log('Count', count()); 41 | // writing to an atom 42 | count(count() + 100); 43 | ``` 44 | 45 | ### Effects 46 | 47 | Effects are the "observers" of `compostate`. When reading a signal or an atom inside effects, effects will automatically mark those as "dependencies", in which when these dependencies update values, effects will automatically re-evaluate. 48 | 49 | ```js 50 | import { signal, syncEffect } from 'compostate'; 51 | 52 | // Create a signal 53 | const [count, setCount] = signal(0); 54 | 55 | // Observe the signal 56 | syncEffect(() => { 57 | console.log('Count:', count()); // Logs 'Count: 0' 58 | }); 59 | 60 | // Update the count 61 | setCount(100); // Logs 'Count: 0' due to the effect 62 | ``` 63 | 64 | When effects re-evaluate, it reconstructs the tracked dependencies from scratch, and so conditional dependency can also be done. 65 | 66 | ```js 67 | syncEffect(() => { 68 | if (someCond()) { 69 | // Subscribe to signalA: this effect will only evaluate 70 | // if signalA changes 71 | doSomething(signalA()); 72 | } else { 73 | // Subscribe to signalB: this effect will only evaluate 74 | // if signalB changes 75 | doOthers(signalB()); 76 | } 77 | }); 78 | ``` 79 | 80 | One can also use `untrack` to prevent an effect from marking a signal as a dependency 81 | 82 | ```js 83 | import { untrack } from 'compostate'; 84 | 85 | syncEffect(() => { 86 | // This effect will access `someSignal` w/o subscribing 87 | const somePassiveSignal = untrack(() => someSignal()); 88 | }); 89 | ``` 90 | 91 | `syncEffect` runs synchronously with signal updates, but this might be undesirable in some cases. An alternative is `effect` which has its evaluation deferred through time-slicing. 92 | 93 | ```js 94 | import { effect, atom } from 'compostate'; 95 | 96 | const greeting = atom('Hello'); 97 | const receiver = atom('Alexis'); 98 | 99 | effect(() => { 100 | // Since the evaluation is deferred, this effect will only 101 | // log after the synchronous code ends. 102 | console.log(`${greeting()}, ${receiver()}!`); 103 | }); 104 | 105 | greeting('Bonjour'); 106 | receiver('Compostate'); 107 | 108 | // At the end of this code, this logs 'Bonjour, Compostate!' 109 | setTimeout(() => { 110 | // The effect is now tracking greeting and receiver 111 | // however like the code above, changes to the atoms 112 | // would not synchronously re-evaluate the effect. 113 | greeting('Hello'); 114 | receiver('Alexis'); 115 | // At the end of this callback, it logs 'Hello, Alexis!' 116 | }, 1000); 117 | ``` 118 | 119 | ### Deriving signals 120 | 121 | Signals and atoms can be composed into derived signals. The basic form of a derived signal uses nothing but a simple function. 122 | 123 | ```js 124 | const count = atom(0); 125 | const squared = () => count() ** 2; 126 | 127 | syncEffect(() => { 128 | console.log(squared()); // 0 129 | }); 130 | 131 | count(4); // 16 132 | ``` 133 | 134 | Normally this is useful but there arises a problem: a derived signal may return the same value but would still trigger a re-evaluation. 135 | 136 | ```js 137 | const message = atom('Hello'); 138 | const length = () => message().length; 139 | 140 | 141 | syncEffect(() => { 142 | console.log('Length:', length()); // Length: 5 143 | }); 144 | 145 | message('Aloha') // Logs again with Length: 5 146 | ``` 147 | 148 | To fix this problem, `computed` can be used in place of the derived signal. 149 | 150 | ```js 151 | import { computed } from 'compostate'; 152 | 153 | const message = atom('Hello'); 154 | const length = computed(() => message().length); 155 | 156 | 157 | syncEffect(() => { 158 | console.log('Length:', length()); // Length: 5 159 | }); 160 | 161 | message('Aloha') // Logs nothing 162 | message('Bonjour') // Length: 7 163 | ``` 164 | 165 | `computed` keeps track of the previously returned value and compares it with the new one, deciding if it should re-evaluate its dependents. 166 | 167 | ### Batching updates 168 | 169 | Signals are cheap to write with, but synchoronous updates can be expensive. For example, if an effect subscribes to multiple signals, whose values are also updated synchronously, the effect may re-evaluate multiple times which is undesirable. The desired result should be for the effect to wait for all the signals to update, and then re-evaluate so that it only has to do it a single time. 170 | 171 | `compostate` provides `batch` to group updates into a single flush. 172 | 173 | ```js 174 | import { syncEffect, atom, batch } from 'compostate'; 175 | 176 | const greeting = atom('Hello'); 177 | const receiver = atom('Alexis'); 178 | 179 | syncEffect(() => { 180 | console.log(`${greeting()}, ${receiver()}!`); // 'Hello, Alexis!' 181 | }); 182 | 183 | // Without batching 184 | greeting('Bonjour'); // 'Bonjour, Alexis!' 185 | receiver('Compostate'); // 'Bonjour, Compostate!' 186 | 187 | // With batching 188 | batch(() => { 189 | greeting('Bonjour'); // Update deferred 190 | receiver('Compostate'); // Update deferred 191 | }); // 'Bonjour, Compostate!' 192 | ``` 193 | 194 | Do take note that in batching, writes are already applied, only the re-evaluation is deferred. 195 | 196 | `compostate` also provides `unbatch` in case flushing updates synchronously is desirable. 197 | 198 | ### Cleanups 199 | 200 | `compostate` provides `onCleanup` which can be called inside tracking calls such as `syncEffect`, `computed`, etc.. Registered cleanup callbacks are evaluated before tracking call are re-evaluated. This is useful when performing side-effects like subscribing to event listeners or making requests. 201 | 202 | ```js 203 | import { onCleanup } from 'compostate'; 204 | 205 | syncEffect(() => { 206 | const request = makeRequest(someSignal()); 207 | 208 | onCleanup(() => { 209 | // When someSignal changes, make sure to cancel 210 | // the current request. 211 | request.cancel(); 212 | }); 213 | }); 214 | ``` 215 | 216 | `onCleanup` will also run if `syncEffect` or `effect` are stopped. 217 | 218 | ```js 219 | const stop = syncEffect(() => { 220 | onCleanup(() => { 221 | console.log('Stopped!'); 222 | }); 223 | }); 224 | 225 | // ... 226 | stop(); 227 | ``` 228 | 229 | Tracking calls are cleanup boundaries, and tracking calls are also cleaned up by their parent cleanup boundaries, so if, for example, an effect is declared inside another effect, the nested effect is stopped when the parent effect is also stopped. 230 | 231 | ```js 232 | const stop = syncEffect(() => { 233 | syncEffect(() => { 234 | onCleanup(() => { 235 | console.log('Stopped!'); 236 | }); 237 | }); 238 | }); 239 | 240 | // ... 241 | stop(); 242 | ``` 243 | 244 | `compostate` also provides `batchCleanup` which is what all tracking calls uses under the hood. `compostate` also provides `unbatchCleanup` if automatic cleanup is undesired. 245 | 246 | ### Error Boundaries 247 | 248 | Like any other code, user code in effects and computations may throw an error. Normal `try`-`catch` won't work in `compostate` since by the time a re-evaluation happen, the try block may have already been escaped. 249 | 250 | To solve this problem, `compostate` provides `errorBoundary` and `onError`. 251 | 252 | ```js 253 | import { errorBoundary, onError } from 'compostate'; 254 | 255 | errorBoundary(() => { 256 | onError((error) => { 257 | console.error(error); 258 | }); 259 | 260 | // Whenever the effect re-evaluation throws 261 | // the error boundary will be able to receive it. 262 | effect(() => doSomeUnsafeWork()); 263 | }); 264 | ``` 265 | 266 | If a given `onError` throws an error on itself, the thrown error and the received error is forwarded to a parent `errorBoundary`. 267 | 268 | If there's a callback that runs outside or uncaptured by `errorBoundary` (e.g. `setTimeout`) and you want the `errorBoundary` to capture it, you can use `captureError`: 269 | 270 | ```js 271 | import { captureError } from 'compostate'; 272 | 273 | errorBoundary(() => { 274 | onError((error) => { 275 | console.error(error); 276 | }); 277 | 278 | const capture = captureError(); 279 | 280 | // Whenever the effect re-evaluation throws 281 | // the error boundary will be able to receive it. 282 | setTimeout(() => { 283 | try { 284 | doSomething(); 285 | } catch (error) { 286 | capture(error); 287 | } 288 | }) 289 | }); 290 | ``` 291 | 292 | ### Context API 293 | 294 | `compostate` provides a way to inject values through function calls, effects and computations 295 | 296 | ```js 297 | import { contextual, createContext, writeContext, readContext } from 'compostate'; 298 | 299 | // Create a context instance with a default value 300 | const message = createContext('Hello World'); 301 | 302 | function log() { 303 | // Read the context value 304 | console.log(readContext(message)); 305 | } 306 | 307 | // Create a context boundary 308 | contextual(() => { 309 | // Write a context value 310 | writeContext(message, 'Ohayo Sekai'); 311 | 312 | log(); // 'Ohayo Sekai' 313 | }); 314 | ``` 315 | 316 | ## Bindings 317 | 318 | - [Web Components](https://github.com/lxsmnsyc/compostate/tree/main/packages/compostate-element) 319 | - [React](https://github.com/lxsmnsyc/compostate/tree/main/packages/react-compostate) 320 | - [Preact](https://github.com/lxsmnsyc/compostate/tree/main/packages/preact-compostate) 321 | 322 | ### Coming Soon 323 | 324 | - SolidJS 325 | - Svelte 326 | - Vue 327 | 328 | ## License 329 | 330 | MIT © [lxsmnsyc](https://github.com/lxsmnsyc) 331 | -------------------------------------------------------------------------------- /packages/compostate/README.md: -------------------------------------------------------------------------------- 1 | # compostate 2 | 3 | > Fine-grained reactivity library 4 | 5 | [![NPM](https://img.shields.io/npm/v/compostate.svg)](https://www.npmjs.com/package/compostate) [![JavaScript Style Guide](https://badgen.net/badge/code%20style/airbnb/ff5a5f?icon=airbnb)](https://github.com/airbnb/javascript) 6 | 7 | ## Install 8 | 9 | ```bash 10 | npm install --save compostate 11 | ``` 12 | 13 | ```bash 14 | yarn add compostate 15 | ``` 16 | 17 | ```bash 18 | pnpm add compostate 19 | ``` 20 | 21 | ## Concepts 22 | 23 | ### Signals and Atoms 24 | 25 | Signals and atoms are the main source of reactivity in `compostate`. They are akin to "subjects" or "observables" in the [Observer pattern](https://en.wikipedia.org/wiki/Observer_pattern). Signals and atoms holds values that can either be read or written. 26 | 27 | ```js 28 | import { signal, atom } from 'compostate'; 29 | 30 | // with signals 31 | const [count, setCount] = signal(0); 32 | // reading a signal 33 | console.log('Count', count()); 34 | // writing to a signal 35 | setCount(count() + 100); 36 | 37 | // with atoms 38 | const count = atom(0); 39 | // reading an atom 40 | console.log('Count', count()); 41 | // writing to an atom 42 | count(count() + 100); 43 | ``` 44 | 45 | ### Effects 46 | 47 | Effects are the "observers" of `compostate`. When reading a signal or an atom inside effects, effects will automatically mark those as "dependencies", in which when these dependencies update values, effects will automatically re-evaluate. 48 | 49 | ```js 50 | import { signal, syncEffect } from 'compostate'; 51 | 52 | // Create a signal 53 | const [count, setCount] = signal(0); 54 | 55 | // Observe the signal 56 | syncEffect(() => { 57 | console.log('Count:', count()); // Logs 'Count: 0' 58 | }); 59 | 60 | // Update the count 61 | setCount(100); // Logs 'Count: 0' due to the effect 62 | ``` 63 | 64 | When effects re-evaluate, it reconstructs the tracked dependencies from scratch, and so conditional dependency can also be done. 65 | 66 | ```js 67 | syncEffect(() => { 68 | if (someCond()) { 69 | // Subscribe to signalA: this effect will only evaluate 70 | // if signalA changes 71 | doSomething(signalA()); 72 | } else { 73 | // Subscribe to signalB: this effect will only evaluate 74 | // if signalB changes 75 | doOthers(signalB()); 76 | } 77 | }); 78 | ``` 79 | 80 | One can also use `untrack` to prevent an effect from marking a signal as a dependency 81 | 82 | ```js 83 | import { untrack } from 'compostate'; 84 | 85 | syncEffect(() => { 86 | // This effect will access `someSignal` w/o subscribing 87 | const somePassiveSignal = untrack(() => someSignal()); 88 | }); 89 | ``` 90 | 91 | `syncEffect` runs synchronously with signal updates, but this might be undesirable in some cases. An alternative is `effect` which has its evaluation deferred through time-slicing. 92 | 93 | ```js 94 | import { effect, atom } from 'compostate'; 95 | 96 | const greeting = atom('Hello'); 97 | const receiver = atom('Alexis'); 98 | 99 | effect(() => { 100 | // Since the evaluation is deferred, this effect will only 101 | // log after the synchronous code ends. 102 | console.log(`${greeting()}, ${receiver()}!`); 103 | }); 104 | 105 | greeting('Bonjour'); 106 | receiver('Compostate'); 107 | 108 | // At the end of this code, this logs 'Bonjour, Compostate!' 109 | setTimeout(() => { 110 | // The effect is now tracking greeting and receiver 111 | // however like the code above, changes to the atoms 112 | // would not synchronously re-evaluate the effect. 113 | greeting('Hello'); 114 | receiver('Alexis'); 115 | // At the end of this callback, it logs 'Hello, Alexis!' 116 | }, 1000); 117 | ``` 118 | 119 | ### Deriving signals 120 | 121 | Signals and atoms can be composed into derived signals. The basic form of a derived signal uses nothing but a simple function. 122 | 123 | ```js 124 | const count = atom(0); 125 | const squared = () => count() ** 2; 126 | 127 | syncEffect(() => { 128 | console.log(squared()); // 0 129 | }); 130 | 131 | count(4); // 16 132 | ``` 133 | 134 | Normally this is useful but there arises a problem: a derived signal may return the same value but would still trigger a re-evaluation. 135 | 136 | ```js 137 | const message = atom('Hello'); 138 | const length = () => message().length; 139 | 140 | 141 | syncEffect(() => { 142 | console.log('Length:', length()); // Length: 5 143 | }); 144 | 145 | message('Aloha') // Logs again with Length: 5 146 | ``` 147 | 148 | To fix this problem, `computed` can be used in place of the derived signal. 149 | 150 | ```js 151 | import { computed } from 'compostate'; 152 | 153 | const message = atom('Hello'); 154 | const length = computed(() => message().length); 155 | 156 | 157 | syncEffect(() => { 158 | console.log('Length:', length()); // Length: 5 159 | }); 160 | 161 | message('Aloha') // Logs nothing 162 | message('Bonjour') // Length: 7 163 | ``` 164 | 165 | `computed` keeps track of the previously returned value and compares it with the new one, deciding if it should re-evaluate its dependents. 166 | 167 | ### Batching updates 168 | 169 | Signals are cheap to write with, but synchoronous updates can be expensive. For example, if an effect subscribes to multiple signals, whose values are also updated synchronously, the effect may re-evaluate multiple times which is undesirable. The desired result should be for the effect to wait for all the signals to update, and then re-evaluate so that it only has to do it a single time. 170 | 171 | `compostate` provides `batch` to group updates into a single flush. 172 | 173 | ```js 174 | import { syncEffect, atom, batch } from 'compostate'; 175 | 176 | const greeting = atom('Hello'); 177 | const receiver = atom('Alexis'); 178 | 179 | syncEffect(() => { 180 | console.log(`${greeting()}, ${receiver()}!`); // 'Hello, Alexis!' 181 | }); 182 | 183 | // Without batching 184 | greeting('Bonjour'); // 'Bonjour, Alexis!' 185 | receiver('Compostate'); // 'Bonjour, Compostate!' 186 | 187 | // With batching 188 | batch(() => { 189 | greeting('Bonjour'); // Update deferred 190 | receiver('Compostate'); // Update deferred 191 | }); // 'Bonjour, Compostate!' 192 | ``` 193 | 194 | Do take note that in batching, writes are already applied, only the re-evaluation is deferred. 195 | 196 | `compostate` also provides `unbatch` in case flushing updates synchronously is desirable. 197 | 198 | ### Cleanups 199 | 200 | `compostate` provides `onCleanup` which can be called inside tracking calls such as `syncEffect`, `computed`, etc.. Registered cleanup callbacks are evaluated before tracking call are re-evaluated. This is useful when performing side-effects like subscribing to event listeners or making requests. 201 | 202 | ```js 203 | import { onCleanup } from 'compostate'; 204 | 205 | syncEffect(() => { 206 | const request = makeRequest(someSignal()); 207 | 208 | onCleanup(() => { 209 | // When someSignal changes, make sure to cancel 210 | // the current request. 211 | request.cancel(); 212 | }); 213 | }); 214 | ``` 215 | 216 | `onCleanup` will also run if `syncEffect` or `effect` are stopped. 217 | 218 | ```js 219 | const stop = syncEffect(() => { 220 | onCleanup(() => { 221 | console.log('Stopped!'); 222 | }); 223 | }); 224 | 225 | // ... 226 | stop(); 227 | ``` 228 | 229 | Tracking calls are cleanup boundaries, and tracking calls are also cleaned up by their parent cleanup boundaries, so if, for example, an effect is declared inside another effect, the nested effect is stopped when the parent effect is also stopped. 230 | 231 | ```js 232 | const stop = syncEffect(() => { 233 | syncEffect(() => { 234 | onCleanup(() => { 235 | console.log('Stopped!'); 236 | }); 237 | }); 238 | }); 239 | 240 | // ... 241 | stop(); 242 | ``` 243 | 244 | `compostate` also provides `batchCleanup` which is what all tracking calls uses under the hood. `compostate` also provides `unbatchCleanup` if automatic cleanup is undesired. 245 | 246 | ### Error Boundaries 247 | 248 | Like any other code, user code in effects and computations may throw an error. Normal `try`-`catch` won't work in `compostate` since by the time a re-evaluation happen, the try block may have already been escaped. 249 | 250 | To solve this problem, `compostate` provides `errorBoundary` and `onError`. 251 | 252 | ```js 253 | import { errorBoundary, onError } from 'compostate'; 254 | 255 | errorBoundary(() => { 256 | onError((error) => { 257 | console.error(error); 258 | }); 259 | 260 | // Whenever the effect re-evaluation throws 261 | // the error boundary will be able to receive it. 262 | effect(() => doSomeUnsafeWork()); 263 | }); 264 | ``` 265 | 266 | If a given `onError` throws an error on itself, the thrown error and the received error is forwarded to a parent `errorBoundary`. 267 | 268 | If there's a callback that runs outside or uncaptured by `errorBoundary` (e.g. `setTimeout`) and you want the `errorBoundary` to capture it, you can use `captureError`: 269 | 270 | ```js 271 | import { captureError } from 'compostate'; 272 | 273 | errorBoundary(() => { 274 | onError((error) => { 275 | console.error(error); 276 | }); 277 | 278 | const capture = captureError(); 279 | 280 | // Whenever the effect re-evaluation throws 281 | // the error boundary will be able to receive it. 282 | setTimeout(() => { 283 | try { 284 | doSomething(); 285 | } catch (error) { 286 | capture(error); 287 | } 288 | }) 289 | }); 290 | ``` 291 | 292 | ### Context API 293 | 294 | `compostate` provides a way to inject values through function calls, effects and computations 295 | 296 | ```js 297 | import { contextual, createContext, writeContext, readContext } from 'compostate'; 298 | 299 | // Create a context instance with a default value 300 | const message = createContext('Hello World'); 301 | 302 | function log() { 303 | // Read the context value 304 | console.log(readContext(message)); 305 | } 306 | 307 | // Create a context boundary 308 | contextual(() => { 309 | // Write a context value 310 | writeContext(message, 'Ohayo Sekai'); 311 | 312 | log(); // 'Ohayo Sekai' 313 | }); 314 | ``` 315 | 316 | ## Bindings 317 | 318 | - [Web Components](https://github.com/lxsmnsyc/compostate/tree/main/packages/compostate-element) 319 | - [React](https://github.com/lxsmnsyc/compostate/tree/main/packages/react-compostate) 320 | - [Preact](https://github.com/lxsmnsyc/compostate/tree/main/packages/preact-compostate) 321 | 322 | ### Coming Soon 323 | 324 | - SolidJS 325 | - Svelte 326 | - Vue 327 | 328 | ## License 329 | 330 | MIT © [lxsmnsyc](https://github.com/lxsmnsyc) 331 | -------------------------------------------------------------------------------- /packages/compostate/src/reactivity/core.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * MIT License 4 | * 5 | * Copyright (c) 2021 Alexis Munsayac 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * 24 | * 25 | * @author Alexis Munsayac 26 | * @copyright Alexis Munsayac 2021 27 | */ 28 | import { 29 | createLinkedWork, 30 | destroyLinkedWork, 31 | enqueuePublisherWork, 32 | enqueueSubscriberWork, 33 | evaluatePublisherWork, 34 | evaluateSubscriberWork, 35 | LinkedWork, 36 | publisherLinkSubscriber, 37 | setRunner, 38 | unlinkLinkedWorkPublishers, 39 | } from '../linked-work'; 40 | import { 41 | cancelCallback, 42 | requestCallback, 43 | Task, 44 | } from '../scheduler'; 45 | import { 46 | Cleanup, 47 | Effect, 48 | ErrorCapture, 49 | Ref, 50 | } from './types'; 51 | 52 | const { is, assign } = Object; 53 | 54 | // Work types 55 | const WORK_ATOM = 0b00000001; 56 | const WORK_COMPUTATION = 0b00000010; 57 | const WORK_EFFECT = 0b00000100; 58 | const WORK_SYNC_EFFECT = 0b00001000; 59 | 60 | // Execution contexts 61 | 62 | // Context for whether the scope is tracking for subscribers 63 | export let TRACKING: LinkedWork | undefined; 64 | // Context for whether the updates are being batched 65 | let BATCH_UPDATES: Set | undefined; 66 | // Context for whether or not there is an error boundary 67 | let ERROR_BOUNDARY: ErrorBoundary | undefined; 68 | // Context for whether there is a context instance 69 | let CONTEXT: ContextTree | undefined; 70 | // Context for whether there is a cleanup boundary 71 | export let CLEANUP: Set | undefined; 72 | 73 | export function unbatch(callback: () => T): T { 74 | const parent = BATCH_UPDATES; 75 | BATCH_UPDATES = undefined; 76 | try { 77 | return callback(); 78 | } finally { 79 | BATCH_UPDATES = parent; 80 | } 81 | } 82 | 83 | export function unbatchCleanup(callback: () => T): T { 84 | const parent = CLEANUP; 85 | CLEANUP = undefined; 86 | try { 87 | return callback(); 88 | } finally { 89 | CLEANUP = parent; 90 | } 91 | } 92 | 93 | export function untrack(callback: () => T): T { 94 | const parent = TRACKING; 95 | TRACKING = undefined; 96 | try { 97 | return callback(); 98 | } finally { 99 | TRACKING = parent; 100 | } 101 | } 102 | 103 | export function createRoot(callback: () => T): T { 104 | const parentBatchUpdates = BATCH_UPDATES; 105 | const parentTracking = TRACKING; 106 | const parentCleanup = CLEANUP; 107 | BATCH_UPDATES = undefined; 108 | TRACKING = undefined; 109 | CLEANUP = undefined; 110 | try { 111 | return callback(); 112 | } finally { 113 | CLEANUP = parentCleanup; 114 | TRACKING = parentTracking; 115 | BATCH_UPDATES = parentBatchUpdates; 116 | } 117 | } 118 | 119 | export function capturedBatchCleanup( 120 | callback: (...args: T) => R, 121 | ): (...args: T) => R { 122 | const current = CLEANUP; 123 | return (...args) => { 124 | const parent = CLEANUP; 125 | CLEANUP = current; 126 | try { 127 | return callback(...args); 128 | } finally { 129 | CLEANUP = parent; 130 | } 131 | }; 132 | } 133 | 134 | export function capturedErrorBoundary( 135 | callback: (...args: T) => R, 136 | ): (...args: T) => R { 137 | const current = ERROR_BOUNDARY; 138 | return (...args) => { 139 | const parent = ERROR_BOUNDARY; 140 | ERROR_BOUNDARY = current; 141 | try { 142 | return callback(...args); 143 | } finally { 144 | ERROR_BOUNDARY = parent; 145 | } 146 | }; 147 | } 148 | 149 | export function capturedContext( 150 | callback: (...args: T) => R, 151 | ): (...args: T) => R { 152 | const current = CONTEXT; 153 | return (...args) => { 154 | const parent = CONTEXT; 155 | CONTEXT = current; 156 | try { 157 | return callback(...args); 158 | } finally { 159 | CONTEXT = parent; 160 | } 161 | }; 162 | } 163 | 164 | export function captured( 165 | callback: (...args: T) => R, 166 | ): (...args: T) => R { 167 | const currentErrorBoundary = ERROR_BOUNDARY; 168 | const currentCleanup = CLEANUP; 169 | const currentContext = CONTEXT; 170 | return (...args) => { 171 | const parentErrorBoundary = ERROR_BOUNDARY; 172 | const parentCleanup = CLEANUP; 173 | const parentContext = CONTEXT; 174 | ERROR_BOUNDARY = currentErrorBoundary; 175 | CLEANUP = currentCleanup; 176 | CONTEXT = currentContext; 177 | try { 178 | return callback(...args); 179 | } finally { 180 | CONTEXT = parentContext; 181 | CLEANUP = parentCleanup; 182 | ERROR_BOUNDARY = parentErrorBoundary; 183 | } 184 | }; 185 | } 186 | 187 | export function onCleanup(cleanup: Cleanup): Cleanup { 188 | if (CLEANUP) { 189 | CLEANUP.add(cleanup); 190 | } 191 | return cleanup; 192 | } 193 | 194 | function exhaustCleanup( 195 | cleanups: Set, 196 | ): void { 197 | for (const cleanup of cleanups) { 198 | cleanup(); 199 | } 200 | } 201 | 202 | export function batchCleanup(callback: () => void): Cleanup { 203 | const cleanups = new Set(); 204 | const parentCleanup = CLEANUP; 205 | CLEANUP = cleanups; 206 | try { 207 | callback(); 208 | } finally { 209 | CLEANUP = parentCleanup; 210 | } 211 | let alive = true; 212 | // Create return cleanup 213 | return onCleanup(() => { 214 | if (alive) { 215 | alive = false; 216 | if (cleanups.size) { 217 | // Untrack before running cleanups 218 | const parent = TRACKING; 219 | TRACKING = undefined; 220 | try { 221 | exhaustCleanup(cleanups); 222 | } finally { 223 | TRACKING = parent; 224 | } 225 | } 226 | } 227 | }); 228 | } 229 | 230 | // ErrorBoundary 231 | interface ErrorBoundary { 232 | calls?: Set; 233 | parent?: ErrorBoundary; 234 | } 235 | 236 | function createErrorBoundary(parent?: ErrorBoundary): ErrorBoundary { 237 | return { parent }; 238 | } 239 | 240 | function runErrorHandlers(calls: IterableIterator, error: unknown): void { 241 | for (const item of calls) { 242 | item(error); 243 | } 244 | } 245 | 246 | function handleError(instance: ErrorBoundary | undefined, error: unknown): void { 247 | if (instance) { 248 | const { calls, parent } = instance; 249 | // Check if the current boundary has listeners 250 | if (calls && calls.size) { 251 | // Untrack before passing error 252 | const parentTracking = TRACKING; 253 | TRACKING = undefined; 254 | try { 255 | runErrorHandlers(calls.keys(), error); 256 | } catch (value) { 257 | // If the error handler fails, forward the new error and the current error 258 | handleError(parent, value); 259 | handleError(parent, error); 260 | } finally { 261 | TRACKING = parentTracking; 262 | } 263 | } else { 264 | // Forward the error to the parent 265 | handleError(parent, error); 266 | } 267 | } else { 268 | throw error; 269 | } 270 | } 271 | 272 | function registerErrorCapture( 273 | instance: ErrorBoundary, 274 | capture: ErrorCapture, 275 | ): Cleanup { 276 | if (!instance.calls) { 277 | instance.calls = new Set(); 278 | } 279 | instance.calls.add(capture); 280 | return () => { 281 | if (instance.calls) { 282 | instance.calls.delete(capture); 283 | } 284 | }; 285 | } 286 | 287 | function NO_OP() { 288 | // no-op 289 | } 290 | 291 | export function onError(errorCapture: ErrorCapture): Cleanup { 292 | if (ERROR_BOUNDARY) { 293 | return onCleanup(registerErrorCapture(ERROR_BOUNDARY, errorCapture)); 294 | } 295 | return NO_OP; 296 | } 297 | 298 | export function errorBoundary(callback: () => T): T { 299 | const parentInstance = ERROR_BOUNDARY; 300 | ERROR_BOUNDARY = createErrorBoundary(parentInstance); 301 | try { 302 | return callback(); 303 | } finally { 304 | ERROR_BOUNDARY = parentInstance; 305 | } 306 | } 307 | 308 | export function captureError(): ErrorCapture { 309 | const boundary = ERROR_BOUNDARY; 310 | return (error) => { 311 | handleError(boundary, error); 312 | }; 313 | } 314 | 315 | /** 316 | * Linked Work 317 | */ 318 | export type ReactiveAtom = LinkedWork; 319 | 320 | export function createReactiveAtom(): ReactiveAtom { 321 | return createLinkedWork(false, WORK_ATOM); 322 | } 323 | 324 | export function destroyReactiveAtom(target: ReactiveAtom): void { 325 | destroyLinkedWork(target); 326 | } 327 | 328 | export function captureReactiveAtomForCleanup(instance: ReactiveAtom): void { 329 | if (CLEANUP) { 330 | CLEANUP.add(() => destroyLinkedWork(instance)); 331 | } 332 | } 333 | 334 | export function trackReactiveAtom(target: ReactiveAtom): void { 335 | publisherLinkSubscriber(target, TRACKING!); 336 | } 337 | 338 | function exhaustUpdates(instance: Set): void { 339 | for (const work of instance) { 340 | if (work.alive) { 341 | if (work.isSubscriber) { 342 | evaluateSubscriberWork(work); 343 | } else { 344 | evaluatePublisherWork(work); 345 | } 346 | } 347 | } 348 | } 349 | 350 | function runUpdates(instance: Set) { 351 | BATCH_UPDATES = instance; 352 | try { 353 | exhaustUpdates(instance); 354 | } finally { 355 | BATCH_UPDATES = undefined; 356 | } 357 | } 358 | 359 | export function notifyReactiveAtom(target: ReactiveAtom): void { 360 | if (target.alive) { 361 | if (BATCH_UPDATES) { 362 | enqueuePublisherWork(target, BATCH_UPDATES); 363 | } else { 364 | const instance = new Set(); 365 | enqueuePublisherWork(target, instance); 366 | runUpdates(instance); 367 | } 368 | } 369 | } 370 | 371 | export function batch( 372 | callback: (...arg: T) => void, 373 | ...args: T 374 | ): void { 375 | if (BATCH_UPDATES) { 376 | callback(...args); 377 | } else { 378 | const instance = new Set(); 379 | BATCH_UPDATES = instance; 380 | try { 381 | callback(...args); 382 | } finally { 383 | BATCH_UPDATES = undefined; 384 | } 385 | runUpdates(instance); 386 | } 387 | } 388 | 389 | function cleanProcess(work: ProcessWork): void { 390 | if (work.cleanup) { 391 | batch(work.cleanup); 392 | work.cleanup = undefined; 393 | } 394 | work.context = undefined; 395 | work.errorBoundary = undefined; 396 | } 397 | 398 | interface ComputationWork extends ProcessWork { 399 | process?: (prev?: T) => T; 400 | current?: T; 401 | } 402 | 403 | export function computation(callback: (prev?: T) => T, initial?: T): Cleanup { 404 | const work: ComputationWork = assign(createLinkedWork(true, WORK_COMPUTATION), { 405 | current: initial, 406 | process: callback, 407 | context: CONTEXT, 408 | errorBoundary: ERROR_BOUNDARY, 409 | }); 410 | 411 | evaluateSubscriberWork(work); 412 | 413 | return onCleanup(() => { 414 | if (work.alive) { 415 | cleanProcess(work); 416 | work.process = undefined; 417 | destroyLinkedWork(work); 418 | } 419 | }); 420 | } 421 | 422 | interface SyncEffectWork extends ProcessWork { 423 | callback?: Effect; 424 | cleanup?: Cleanup; 425 | } 426 | 427 | export function syncEffect(callback: Effect): Cleanup { 428 | const work: SyncEffectWork = assign(createLinkedWork(true, WORK_SYNC_EFFECT), { 429 | callback, 430 | context: CONTEXT, 431 | errorBoundary: ERROR_BOUNDARY, 432 | }); 433 | 434 | evaluateSubscriberWork(work); 435 | 436 | return onCleanup(() => { 437 | if (work.alive) { 438 | cleanProcess(work); 439 | work.callback = undefined; 440 | destroyLinkedWork(work); 441 | } 442 | }); 443 | } 444 | interface EffectWork extends ProcessWork { 445 | callback?: Effect; 446 | cleanup?: Cleanup; 447 | timeout?: ReturnType; 448 | } 449 | 450 | export function effect(callback: Effect): Cleanup { 451 | const work: EffectWork = assign(createLinkedWork(true, WORK_EFFECT), { 452 | callback, 453 | context: CONTEXT, 454 | errorBoundary: ERROR_BOUNDARY, 455 | }); 456 | 457 | evaluateSubscriberWork(work); 458 | 459 | return onCleanup(() => { 460 | if (work.alive) { 461 | cleanProcess(work); 462 | if (work.timeout) { 463 | cancelCallback(work.timeout); 464 | } 465 | work.timeout = undefined; 466 | work.callback = undefined; 467 | destroyLinkedWork(work); 468 | } 469 | }); 470 | } 471 | 472 | interface WatchRef { 473 | current: T; 474 | } 475 | 476 | export function watch( 477 | source: () => T, 478 | listen: (next: T, prev?: T) => R, 479 | isEqual: (next: T, prev: T) => boolean = is, 480 | ): () => R { 481 | let ref: WatchRef | undefined; 482 | let result: WatchRef | undefined; 483 | let cleanup: Cleanup | undefined; 484 | 485 | return () => { 486 | const next = source(); 487 | const prev = ref?.current; 488 | if ((ref && !isEqual(next, ref.current)) || !ref) { 489 | if (cleanup) { 490 | cleanup(); 491 | } 492 | cleanup = batchCleanup(() => { 493 | ref = { current: next }; 494 | result = { current: listen(next, prev) }; 495 | }); 496 | } 497 | if (!result) { 498 | throw new Error('Unexpected missing result'); 499 | } 500 | return result.current; 501 | }; 502 | } 503 | 504 | export type Signal = [ 505 | () => T, 506 | (value: T) => void, 507 | ]; 508 | 509 | export function signal( 510 | value: T, 511 | isEqual: (next: T, prev: T) => boolean = is, 512 | ): Signal { 513 | const instance = createReactiveAtom(); 514 | captureReactiveAtomForCleanup(instance); 515 | return [ 516 | () => { 517 | if (TRACKING) { 518 | trackReactiveAtom(instance); 519 | } 520 | return value; 521 | }, 522 | (next) => { 523 | if (!isEqual(next, value)) { 524 | value = next; 525 | notifyReactiveAtom(instance); 526 | } 527 | }, 528 | ]; 529 | } 530 | 531 | export interface Atom { 532 | (): T; 533 | (next: T): T; 534 | } 535 | 536 | export function atom(value: T, isEqual: (next: T, prev: T) => boolean = is): Atom { 537 | const instance = createReactiveAtom(); 538 | captureReactiveAtomForCleanup(instance); 539 | return (...args: [] | [T]) => { 540 | if (args.length === 1) { 541 | const next = args[0]; 542 | if (!isEqual(next, value)) { 543 | value = next; 544 | notifyReactiveAtom(instance); 545 | } 546 | } else if (TRACKING) { 547 | trackReactiveAtom(instance); 548 | } 549 | return value; 550 | }; 551 | } 552 | 553 | export function computed( 554 | compute: () => T, 555 | isEqual: (next: T, prev: T) => boolean = is, 556 | ): () => T { 557 | const instance = createReactiveAtom(); 558 | captureReactiveAtomForCleanup(instance); 559 | 560 | let value: T; 561 | let initial = true; 562 | let doSetup = true; 563 | 564 | const setup = captured(() => { 565 | syncEffect( 566 | watch(compute, (current) => { 567 | value = current; 568 | if (initial) { 569 | initial = false; 570 | } else { 571 | notifyReactiveAtom(instance); 572 | } 573 | }, isEqual), 574 | ); 575 | }); 576 | 577 | return () => { 578 | if (doSetup) { 579 | setup(); 580 | doSetup = false; 581 | } 582 | if (TRACKING) { 583 | trackReactiveAtom(instance); 584 | } 585 | return value; 586 | }; 587 | } 588 | 589 | function processWork(target: ProcessWork, work: (target: ProcessWork) => void) { 590 | unlinkLinkedWorkPublishers(target); 591 | const parentContext = CONTEXT; 592 | const parentTracking = TRACKING; 593 | const parentErrorBoundary = ERROR_BOUNDARY; 594 | ERROR_BOUNDARY = target.errorBoundary; 595 | TRACKING = target; 596 | CONTEXT = target.context; 597 | try { 598 | work(target); 599 | } catch (value) { 600 | handleError(target.errorBoundary, value); 601 | } finally { 602 | CONTEXT = parentContext; 603 | TRACKING = parentTracking; 604 | ERROR_BOUNDARY = parentErrorBoundary; 605 | } 606 | } 607 | 608 | interface ProcessWork extends LinkedWork { 609 | cleanup?: Cleanup; 610 | errorBoundary?: ErrorBoundary; 611 | context?: ContextTree; 612 | } 613 | 614 | function runComputationProcessInternal( 615 | target: ComputationWork, 616 | process: (prev?: T) => T, 617 | ) { 618 | if (target.cleanup) { 619 | target.cleanup(); 620 | } 621 | target.cleanup = batchCleanup(() => { 622 | target.current = process(target.current); 623 | }); 624 | } 625 | 626 | function runComputationProcess(target: ComputationWork) { 627 | const { process } = target; 628 | if (process) { 629 | batch(runComputationProcessInternal, target, process); 630 | } 631 | } 632 | 633 | function runSyncEffectProcessInternal( 634 | target: SyncEffectWork, 635 | callback: Effect, 636 | ) { 637 | if (target.cleanup) { 638 | target.cleanup(); 639 | } 640 | target.cleanup = batchCleanup(callback); 641 | } 642 | 643 | function runSyncEffectProcess(target: SyncEffectWork) { 644 | const { callback } = target; 645 | if (callback) { 646 | batch(runSyncEffectProcessInternal, target, callback); 647 | } 648 | } 649 | 650 | function runEffectProcess(target: EffectWork) { 651 | const newCallback = captured(() => { 652 | processWork(target, runSyncEffectProcess); 653 | }); 654 | 655 | if (target.timeout) { 656 | cancelCallback(target.timeout); 657 | } 658 | target.timeout = requestCallback(newCallback); 659 | } 660 | 661 | function runProcess(target: ProcessWork) { 662 | switch (target.tag) { 663 | case WORK_COMPUTATION: 664 | processWork(target, runComputationProcess); 665 | break; 666 | case WORK_EFFECT: 667 | runEffectProcess(target as EffectWork); 668 | break; 669 | case WORK_SYNC_EFFECT: 670 | processWork(target, runSyncEffectProcess); 671 | break; 672 | default: 673 | break; 674 | } 675 | } 676 | 677 | setRunner(runProcess); 678 | 679 | interface ContextTree { 680 | parent?: ContextTree; 681 | data: Record | undefined>; 682 | } 683 | 684 | export function contextual(callback: () => T): T { 685 | const parent = CONTEXT; 686 | CONTEXT = { 687 | parent, 688 | data: {}, 689 | }; 690 | try { 691 | return callback(); 692 | } finally { 693 | CONTEXT = parent; 694 | } 695 | } 696 | 697 | export interface Context { 698 | id: number; 699 | defaultValue: T; 700 | } 701 | 702 | let CONTEXT_ID = 0; 703 | 704 | export function createContext(defaultValue: T): Context { 705 | return { 706 | id: CONTEXT_ID++, 707 | defaultValue, 708 | }; 709 | } 710 | 711 | export function writeContext(context: Context, value: T): void { 712 | const parent = CONTEXT; 713 | if (parent) { 714 | parent.data[context.id] = { value }; 715 | 716 | // If provide is called in a linked work, 717 | // make sure to delete the written data. 718 | if (CLEANUP) { 719 | CLEANUP.add(() => { 720 | parent.data[context.id] = undefined; 721 | }); 722 | } 723 | } 724 | } 725 | 726 | export function readContext(context: Context): T { 727 | let current = CONTEXT; 728 | while (current) { 729 | const currentData = current.data[context.id]; 730 | if (currentData) { 731 | return currentData.value; 732 | } 733 | if (CONTEXT) { 734 | current = CONTEXT.parent; 735 | } else { 736 | break; 737 | } 738 | } 739 | return context.defaultValue; 740 | } 741 | 742 | export function selector( 743 | source: () => T, 744 | isEqual: (a: U, b: T) => boolean = is, 745 | ): (item: U) => boolean { 746 | const subs = new Map>(); 747 | let v: T; 748 | syncEffect( 749 | watch(source, (current, prev) => { 750 | for (const key of subs.keys()) { 751 | if (isEqual(key, current) || (prev !== undefined && isEqual(key, prev))) { 752 | const listeners = subs.get(key); 753 | if (listeners && listeners.size) { 754 | for (const listener of listeners) { 755 | if (listener.alive) { 756 | enqueueSubscriberWork(listener, BATCH_UPDATES!); 757 | } 758 | } 759 | } 760 | } 761 | } 762 | v = current; 763 | }), 764 | ); 765 | return (key: U) => { 766 | const current = TRACKING; 767 | if (current) { 768 | let listeners: Set; 769 | const currentListeners = subs.get(key); 770 | if (currentListeners) { 771 | listeners = currentListeners; 772 | } else { 773 | listeners = new Set([current]); 774 | subs.set(key, listeners); 775 | } 776 | if (CLEANUP) { 777 | CLEANUP.add(() => { 778 | if (listeners.size > 1) { 779 | listeners.delete(current); 780 | } else { 781 | subs.delete(key); 782 | } 783 | }); 784 | } 785 | } 786 | return isEqual(key, v); 787 | }; 788 | } 789 | 790 | const TRANSITIONS = new Set(); 791 | const [readTransitionPending, writeTransitionPending] = signal(false); 792 | let task: Task | undefined; 793 | 794 | function flushTransition() { 795 | writeTransitionPending(false); 796 | task = undefined; 797 | if (TRANSITIONS.size) { 798 | const transitions = new Set(TRANSITIONS); 799 | // Clear the original so that 800 | // the next transitions are 801 | // deferred 802 | TRANSITIONS.clear(); 803 | runUpdates(transitions); 804 | } 805 | } 806 | 807 | function scheduleTransition() { 808 | writeTransitionPending(true); 809 | if (task) { 810 | cancelCallback(task); 811 | } 812 | task = requestCallback(flushTransition); 813 | } 814 | 815 | export function startTransition(callback: () => void): void { 816 | const parent = BATCH_UPDATES; 817 | BATCH_UPDATES = TRANSITIONS; 818 | try { 819 | callback(); 820 | } finally { 821 | BATCH_UPDATES = parent; 822 | } 823 | scheduleTransition(); 824 | } 825 | 826 | export function isTransitionPending(): boolean { 827 | return readTransitionPending(); 828 | } 829 | 830 | export function deferred( 831 | callback: () => T, 832 | isEqual: (next: T, prev: T) => boolean = is, 833 | ): () => T { 834 | const instance = createReactiveAtom(); 835 | captureReactiveAtomForCleanup(instance); 836 | 837 | let value: T; 838 | 839 | const setup = captured(() => { 840 | effect(() => { 841 | const next = callback(); 842 | if (!isEqual(value, next)) { 843 | value = next; 844 | notifyReactiveAtom(instance); 845 | } 846 | }); 847 | }); 848 | 849 | let doSetup = true; 850 | 851 | return () => { 852 | if (doSetup) { 853 | value = untrack(callback); 854 | setup(); 855 | doSetup = false; 856 | } 857 | if (TRACKING) { 858 | trackReactiveAtom(instance); 859 | } 860 | return value; 861 | }; 862 | } 863 | --------------------------------------------------------------------------------