├── .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 |
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 | [](https://www.npmjs.com/package/react-compostate) [](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 | [](https://www.npmjs.com/package/preact-compostate) [](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 | [](https://www.npmjs.com/package/compostate-element) [](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 | [](https://www.npmjs.com/package/compostate) [](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 | [](https://www.npmjs.com/package/compostate) [](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 |
--------------------------------------------------------------------------------
]