├── .idea ├── .gitignore ├── codeStyles │ └── codeStyleConfig.xml ├── misc.xml ├── vcs.xml ├── prettier.xml ├── modules.xml ├── zeact.iml └── watcherTasks.xml ├── globals.d.ts ├── src ├── dom-tests │ ├── empty-component.test.tsx │ ├── test-helpers.ts │ ├── key-order-bug.test.ts │ ├── key-order-bug.ts │ ├── simple-switcher-z.test.ts │ ├── test-scheduling-z.test.tsx │ ├── host.test.ts │ └── deletion.test.ts ├── lib │ ├── observables │ │ ├── observables.md │ │ ├── change │ │ │ ├── change.ts │ │ │ └── change.test.ts │ │ ├── value-style.ts │ │ ├── $set.ts │ │ ├── computed │ │ │ ├── computed-run.test.ts │ │ │ ├── computed-get.test.ts │ │ │ ├── computed-behaviour.md │ │ │ ├── computed.ts │ │ │ └── chained-computed.test.ts │ │ ├── memory.test.ts │ │ ├── $component.ts │ │ ├── $map.ts │ │ ├── atom.ts │ │ ├── notifier.ts │ │ ├── global-stack.ts │ │ ├── action.test.ts │ │ ├── observable.test.ts │ │ ├── responder.ts │ │ ├── $model.ts │ │ ├── $component.test.ts │ │ ├── action.ts │ │ ├── $model.test.ts │ │ ├── run-stack.ts │ │ └── decorator.test.ts │ ├── util │ │ ├── ref.ts │ │ ├── style.ts │ │ ├── o-array.ts │ │ ├── ordered-array.test.ts │ │ ├── measure.ts │ │ ├── element.ts │ │ ├── order.test.ts │ │ ├── style.test.ts │ │ └── order.ts │ ├── component-types │ │ ├── fragment.ts │ │ ├── base-component.ts │ │ ├── root-component.ts │ │ ├── host │ │ │ ├── dom-components.ts │ │ │ ├── dom-component-types.ts │ │ │ ├── svg-components.ts │ │ │ ├── set-attributes.test.ts │ │ │ ├── set-attributes.ts │ │ │ ├── dom-component.ts │ │ │ └── dom-component.test.ts │ │ ├── text-component.ts │ │ ├── fragment.test.ts │ │ ├── component.test.ts │ │ ├── keys.test.ts │ │ └── pure-component.ts │ ├── render.test.ts │ ├── misc │ │ ├── compare.test.ts │ │ └── compare-speed.test.ts │ ├── render.ts │ └── create-element.test.ts ├── index.ts └── demos │ ├── lots-of-elements.tsx │ ├── boxes.ts │ └── canvas.ts ├── prettier.config.js ├── jest.config.js ├── .eslintrc.js ├── tsconfig.json ├── package.json ├── README.md ├── .gitignore └── LICENSE /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /workspace.xml 3 | -------------------------------------------------------------------------------- /globals.d.ts: -------------------------------------------------------------------------------- 1 | // 2 | 3 | declare const __DEV__: boolean 4 | declare const __FIEND_DEV__: boolean 5 | -------------------------------------------------------------------------------- /src/dom-tests/empty-component.test.tsx: -------------------------------------------------------------------------------- 1 | describe('empty component', () => { 2 | test('simple', () => { 3 | // 4 | }) 5 | }) 6 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | singleQuote: true, 4 | bracketSpacing: false, 5 | printWidth: 90, 6 | arrowParens: 'avoid', 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/observables/observables.md: -------------------------------------------------------------------------------- 1 | # Observable Notes 2 | 3 | ## Keep Alive Rules 4 | 5 | ### Computed 6 | - A computed has any active responders in it's notify list. 7 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /src/dom-tests/test-helpers.ts: -------------------------------------------------------------------------------- 1 | import {RootComponent} from '../lib/component-types/root-component' 2 | 3 | export function mkRoot(): RootComponent { 4 | return new RootComponent(document.createElement('div')) 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/util/ref.ts: -------------------------------------------------------------------------------- 1 | export interface RefObject { 2 | current: T | null 3 | } 4 | 5 | export function createRef(): RefObject { 6 | return { 7 | current: null, 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/lib/component-types/fragment.ts: -------------------------------------------------------------------------------- 1 | import {PureComponent} from './pure-component' 2 | import {FiendNode} from '../util/element' 3 | 4 | class Fragment extends PureComponent { 5 | render() { 6 | return this.props.children ?? [] 7 | } 8 | } 9 | 10 | export function $F(...children: FiendNode[]) { 11 | return Fragment.$({children}) 12 | } 13 | -------------------------------------------------------------------------------- /.idea/zeact.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globals: { 3 | __DEV__: false, 4 | __FIEND_DEV__: false, 5 | }, 6 | roots: ['/src/'], 7 | transform: { 8 | '.(ts|tsx)': 'ts-jest', 9 | }, 10 | testRegex: '(\\.(test))\\.(ts|tsx)$', 11 | moduleFileExtensions: ['ts', 'tsx', 'js'], 12 | coverageDirectory: '/coverage~~', 13 | collectCoverageFrom: ['src/**/*.{ts,tsx}'], 14 | testEnvironment: 'jsdom', 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/observables/change/change.ts: -------------------------------------------------------------------------------- 1 | import {$Reaction} from '../responder' 2 | 3 | // Wait for an observable to change with a promise. 4 | export function $Change( 5 | store: S, 6 | observableName: K 7 | ): Promise { 8 | return new Promise(resolve => { 9 | const end = $Reaction( 10 | () => store[observableName], 11 | value => { 12 | end() 13 | resolve(value) 14 | } 15 | ) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [], 4 | plugins: ['@typescript-eslint'], 5 | parserOptions: { 6 | ecmaVersion: 2018, 7 | sourceType: 'module', // Allows for the use of imports 8 | ecmaFeatures: { 9 | jsx: true 10 | }, 11 | project: './tsconfig.json' 12 | }, 13 | rules: { 14 | 'react/no-unescaped-entities': 0, 15 | '@typescript-eslint/strict-boolean-expressions': 2 16 | }, 17 | settings: { 18 | react: { 19 | version: 'detect' 20 | } 21 | }, 22 | ignorePatterns: ['*.config.ts'] 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/util/style.ts: -------------------------------------------------------------------------------- 1 | // This just returns the string that would have been without the function call. 2 | export function s( 3 | literals: TemplateStringsArray, 4 | ...placeholders: (string | number)[] 5 | ): string { 6 | return literals.map((str, i) => str + (placeholders[i] ?? '')).join('') 7 | } 8 | 9 | export const t = s 10 | 11 | export function c(names: TemplateStringsArray, ...flags: boolean[]): string { 12 | if (names.length === 1) return names[0] 13 | 14 | let classes = '' 15 | for (let i = 0; i < names.length; i++) { 16 | if (flags[i]) classes += names[i] 17 | } 18 | return classes.trim() 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "es2018", 5 | "experimentalDecorators": true, 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "jsx": "react", 9 | "jsxFactory": "createElement", 10 | "skipLibCheck": true, 11 | "sourceMap": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "strict": true 15 | }, 16 | "include": [ 17 | "src", 18 | "test", 19 | "globals.d.ts" 20 | ], 21 | "exclude": [ 22 | "node_modules", 23 | "dist", 24 | "target~~", 25 | "output-code" 26 | ], 27 | "compileOnSave": false 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/observables/change/change.test.ts: -------------------------------------------------------------------------------- 1 | import {makeObservable} from '../$model' 2 | import {$Change} from './change' 3 | 4 | describe('$Change', () => { 5 | class Store { 6 | $o: 'start' | 'middle' | 'end' = 'start' 7 | 8 | constructor() { 9 | makeObservable(this) 10 | } 11 | } 12 | 13 | test('wait for new value with promise', async () => { 14 | const s = new Store() 15 | 16 | expect(s.$o).toBe('start') 17 | 18 | const next = $Change(s, '$o') 19 | 20 | s.$o = 'middle' 21 | 22 | expect(await next).toBe('middle') 23 | 24 | const next2 = $Change(s, '$o') 25 | 26 | s.$o = 'end' 27 | 28 | expect(await next2).toBe('end') 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/lib/component-types/base-component.ts: -------------------------------------------------------------------------------- 1 | import {DomComponent} from './host/dom-component' 2 | import {TextComponent} from './text-component' 3 | import {PureComponent} from './pure-component' 4 | import {RootComponent} from './root-component' 5 | 6 | export type AnyComponent = DomComponent | TextComponent | PureComponent 7 | export type ParentComponent = PureComponent | RootComponent | DomComponent 8 | export type ElementComponent = DomComponent | TextComponent 9 | 10 | export enum ComponentType { 11 | host, 12 | custom, 13 | text, 14 | } 15 | 16 | export interface ComponentBase { 17 | _type: ComponentType 18 | domParent: DomComponent | RootComponent 19 | order: string 20 | 21 | // Remove the component and run cleanup. Not necessarily related to element removal. 22 | remove(): void 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fiend-ui", 3 | "version": "0.1.0", 4 | "description": "UI library inspired by React and Mobx", 5 | "homepage": "https://github.com/GitFiend/fiend-ui", 6 | "scripts": { 7 | "test": "jest --config ./jest.config.js --watch" 8 | }, 9 | "author": "Toby Suggate ", 10 | "license": "Apache-2.0", 11 | "devDependencies": { 12 | "@testing-library/dom": "^9.3.3", 13 | "@types/jest": "^29.5.5", 14 | "dotenv": "^16.3.1", 15 | "jest": "^29.7.0", 16 | "jest-environment-jsdom": "^29.7.0", 17 | "mobx": "^5.15.4", 18 | "prettier": "^3.0.3", 19 | "ts-jest": "^29.1.1", 20 | "ts-node": "^10.9.1", 21 | "tslib": "^2.6.2", 22 | "typescript": "^5.2.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/util/o-array.ts: -------------------------------------------------------------------------------- 1 | export interface OElement { 2 | order: string 3 | } 4 | 5 | export class OArray { 6 | static insert(array: OElement[], element: OElement): number { 7 | const {order} = element 8 | 9 | for (let i = 0; i < array.length; i++) { 10 | const current = array[i] 11 | 12 | if (order < current.order) { 13 | array.splice(i, 0, element) 14 | return i 15 | } else if (order === current.order) { 16 | array[i] = element 17 | return i 18 | } 19 | } 20 | 21 | array.push(element) 22 | return array.length - 1 23 | } 24 | 25 | static remove(array: OElement[], element: OElement): void { 26 | const {order} = element 27 | 28 | for (let i = 0; i < array.length; i++) { 29 | if (array[i].order === order) { 30 | array.splice(i, 1) 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/util/ordered-array.test.ts: -------------------------------------------------------------------------------- 1 | import {OArray, OElement} from './o-array' 2 | 3 | describe('OArray operations', () => { 4 | const a = {order: 'a'} 5 | const b = {order: 'b'} 6 | const c = {order: 'c'} 7 | const z = {order: 'z'} 8 | 9 | test('inserts at correct location', () => { 10 | const array: OElement[] = [] 11 | 12 | OArray.insert(array, c) 13 | expect(array).toEqual([c]) 14 | 15 | OArray.insert(array, a) 16 | expect(array).toEqual([a, c]) 17 | OArray.insert(array, a) 18 | expect(array).toEqual([a, c]) 19 | 20 | OArray.insert(array, z) 21 | expect(array).toEqual([a, c, z]) 22 | 23 | OArray.insert(array, b) 24 | expect(array).toEqual([a, b, c, z]) 25 | }) 26 | 27 | test('remove elements', () => { 28 | const array = [a, b, c, z] 29 | 30 | OArray.remove(array, b) 31 | expect(array).toEqual([a, c, z]) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {$RunInAction, $Action, $AsyncAction} from './lib/observables/action' 2 | export * from './lib/component-types/host/dom-component-types' 3 | export {render} from './lib/render' 4 | export * from './lib/util/ref' 5 | export * from './lib/component-types/host/dom-components' 6 | export {PureComponent} from './lib/component-types/pure-component' 7 | export {$Component} from './lib/observables/$component' 8 | export * from './lib/observables/$set' 9 | export * from './lib/observables/$map' 10 | export {makeObservable, getObservableUntracked, $Model} from './lib/observables/$model' 11 | export {$AutoRun, $Reaction} from './lib/observables/responder' 12 | export * from './lib/util/style' 13 | export * from './lib/util/element' 14 | export * from './lib/component-types/host/svg-components' 15 | export {$Val} from './lib/observables/value-style' 16 | export {$Change} from './lib/observables/change/change' 17 | -------------------------------------------------------------------------------- /src/lib/util/measure.ts: -------------------------------------------------------------------------------- 1 | // 2 | 3 | export function time(name: string): void { 4 | if (__DEV__ && typeof window !== 'undefined') { 5 | performance.mark(`${name} start`) 6 | } 7 | } 8 | 9 | export function timeEnd(name: string): void { 10 | if (__DEV__ && typeof window !== 'undefined') { 11 | performance.mark(`${name} end`) 12 | performance.measure(name, `${name} start`, `${name} end`) 13 | } 14 | } 15 | 16 | export function timeF(f: () => void, name: string) { 17 | time(name) 18 | f() 19 | timeEnd(name) 20 | } 21 | 22 | export function timeF2(f: () => void, name: string) { 23 | console.time(name) 24 | f() 25 | console.timeEnd(name) 26 | } 27 | 28 | /* 29 | performance.mark('Iteration Start') 30 | Iteration() 31 | performance.mark('Iteration End') 32 | performance.measure( 33 | 'Iteration', 34 | 'Iteration Start', 35 | 'Iteration End' 36 | ) 37 | */ 38 | -------------------------------------------------------------------------------- /src/dom-tests/key-order-bug.test.ts: -------------------------------------------------------------------------------- 1 | import {mkRoot} from './test-helpers' 2 | import {ScaleElements} from './key-order-bug' 3 | 4 | describe('key order bug', () => { 5 | test('Scale at position 0', () => { 6 | const root = mkRoot() 7 | 8 | root.render(ScaleElements.$({position: 0})) 9 | 10 | expect(root.element.innerHTML).toEqual( 11 | '
element 0
element 1
element 2
' 12 | ) 13 | 14 | root.render(ScaleElements.$({position: 1})) 15 | 16 | expect(root.element.innerHTML).toEqual( 17 | '
element 1
element 2
element 3
' 18 | ) 19 | 20 | root.render(ScaleElements.$({position: 7})) 21 | root.render(ScaleElements.$({position: 1})) 22 | root.render(ScaleElements.$({position: 0})) 23 | 24 | expect(root.element.innerHTML).toEqual( 25 | '
element 0
element 1
element 2
' 26 | ) 27 | 28 | // console.log(root.element.innerHTML) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/lib/util/element.ts: -------------------------------------------------------------------------------- 1 | import type {CustomComponent} from '../component-types/pure-component' 2 | 3 | export interface StandardProps { 4 | children?: FiendNode[] 5 | key?: string 6 | } 7 | 8 | export enum ElementNamespace { 9 | html, 10 | svg, 11 | } 12 | 13 | export enum ElementType { 14 | dom, 15 | custom, 16 | } 17 | 18 | export type FiendElement

= 19 | | HostElement

20 | | SvgElement

21 | | CustomElement

22 | 23 | export interface HostElement

{ 24 | elementType: ElementType.dom 25 | _type: keyof HTMLElementTagNameMap 26 | namespace: ElementNamespace.html 27 | props: P 28 | } 29 | export interface SvgElement

{ 30 | elementType: ElementType.dom 31 | _type: keyof SVGElementTagNameMap 32 | namespace: ElementNamespace.svg 33 | props: P 34 | } 35 | export interface CustomElement

{ 36 | elementType: ElementType.custom 37 | _type: CustomComponent

38 | props: P 39 | } 40 | 41 | export type FiendNode = FiendElement | string | null 42 | -------------------------------------------------------------------------------- /src/lib/observables/value-style.ts: -------------------------------------------------------------------------------- 1 | import {globalStack} from './global-stack' 2 | import {Computed} from './computed/computed' 3 | import {Atom} from './atom' 4 | 5 | export interface Calc { 6 | (): Readonly 7 | 8 | length: Symbol // This is to prevent accidental comparisons. 9 | } 10 | 11 | export function $Calc(f: () => T): Calc { 12 | const c = new Computed(f, f.name) 13 | 14 | return (() => { 15 | return c.get(globalStack.getCurrentResponder()) 16 | }) as any 17 | } 18 | 19 | export interface Observable { 20 | (): Readonly 21 | 22 | (newValue: T): T 23 | 24 | length: Symbol // We override length to be symbol to prevent accidental length checks. 25 | } 26 | 27 | export function $Val(value: T): Observable { 28 | const a = new Atom(value, '$Val') 29 | 30 | function inner(): T 31 | function inner(newValue: T): T 32 | function inner(newValue?: T) { 33 | if (arguments.length === 0) { 34 | return a.get(globalStack.getCurrentResponder()) 35 | } 36 | 37 | if (newValue !== undefined) a.set(newValue) 38 | 39 | return newValue 40 | } 41 | 42 | return inner as Observable 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/observables/$set.ts: -------------------------------------------------------------------------------- 1 | import {Atom} from './atom' 2 | import {globalStack} from './global-stack' 3 | 4 | export class $Set { 5 | #set: Set 6 | #size: Atom = new Atom(0, 'size') 7 | 8 | constructor(items: T[] = []) { 9 | this.#set = new Set(items) 10 | } 11 | 12 | add(item: T): this { 13 | this.#set.add(item) 14 | this.#size.set(this.#set.size) 15 | 16 | return this 17 | } 18 | 19 | delete(item: T): boolean { 20 | const deleted = this.#set.delete(item) 21 | 22 | if (deleted) { 23 | this.#size.set(this.#set.size) 24 | } 25 | 26 | return deleted 27 | } 28 | 29 | has(item: T): boolean { 30 | return this.#size.get(globalStack.getCurrentResponder()) > 0 && this.#set.has(item) 31 | } 32 | 33 | clear(): void { 34 | this.#set.clear() 35 | this.#size.set(0) 36 | } 37 | 38 | forEach(callbackfn: (value: T, value2: T, set: Set) => void, thisArg?: any): void { 39 | if (this.#size.get(globalStack.getCurrentResponder()) > 0) { 40 | this.#set.forEach(callbackfn, thisArg) 41 | } 42 | } 43 | 44 | get size(): number { 45 | return this.#size.get(globalStack.getCurrentResponder()) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/dom-tests/key-order-bug.ts: -------------------------------------------------------------------------------- 1 | import {Div} from '../lib/component-types/host/dom-components' 2 | import {FiendNode} from '../lib/util/element' 3 | import {PureComponent} from '../lib/component-types/pure-component' 4 | 5 | const numElements = 3 6 | 7 | export class ScaleElements extends PureComponent<{position: number}> { 8 | render() { 9 | const {position} = this.props 10 | 11 | const elements: FiendNode[] = [] 12 | 13 | for (let i = position; i < position + numElements; i++) { 14 | const text = `element ${i}` 15 | 16 | elements.push( 17 | Element.$({ 18 | text, 19 | key: text, 20 | }), 21 | ) 22 | } 23 | 24 | return Div({children: elements}) 25 | } 26 | } 27 | 28 | class Element extends PureComponent<{text: string}> { 29 | render() { 30 | const {text} = this.props 31 | 32 | return ElementInner.$({ 33 | key: text, 34 | text, 35 | }) 36 | } 37 | } 38 | 39 | interface Props { 40 | text: string 41 | } 42 | 43 | class ElementInner extends PureComponent { 44 | render() { 45 | const {text} = this.props 46 | 47 | return Div({ 48 | key: text, 49 | children: [text], 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/dom-tests/simple-switcher-z.test.ts: -------------------------------------------------------------------------------- 1 | import {render} from '..' 2 | import {screen} from '@testing-library/dom' 3 | import {$Component} from '..' 4 | import {Div} from '..' 5 | import {$Val} from '../lib/observables/value-style' 6 | 7 | export function sleep(ms: number) { 8 | return new Promise(resolve => { 9 | setTimeout(() => { 10 | resolve() 11 | }, ms) 12 | }) 13 | } 14 | 15 | describe('simple switching with own observer library', () => { 16 | test('shows alternate div after state update', async () => { 17 | const store = new Store() 18 | 19 | const s = Switcher.$({store}) 20 | render(s, document.body) 21 | 22 | expect(screen.queryByText('a')).toBeDefined() 23 | expect(screen.queryByText('b')).toBeNull() 24 | 25 | store.a(false) 26 | 27 | // await sleep(1) 28 | 29 | expect(screen.queryByText('b')).toBeDefined() 30 | expect(screen.queryByText('a')).toBeNull() 31 | }) 32 | }) 33 | 34 | class Store { 35 | a = $Val(true) 36 | } 37 | 38 | interface SwitcherProps { 39 | store: Store 40 | } 41 | 42 | class Switcher extends $Component { 43 | render() { 44 | const {store} = this.props 45 | 46 | if (store.a()) { 47 | return Div('a') 48 | } 49 | 50 | return Div('b') 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/lib/observables/computed/computed-run.test.ts: -------------------------------------------------------------------------------- 1 | import {$AutoRun, F0} from '../responder' 2 | import {makeObservable} from '../$model' 3 | 4 | describe('computed run behaviour', () => { 5 | class A { 6 | d: F0[] = [] 7 | runs = 0 8 | 9 | $o = 0 10 | 11 | constructor() { 12 | makeObservable(this) 13 | } 14 | 15 | get $c() { 16 | this.runs++ 17 | return this.$o 18 | } 19 | 20 | result = 0 21 | 22 | enableReactions() { 23 | this.d.push( 24 | $AutoRun(() => { 25 | this.result = this.$c 26 | }) 27 | ) 28 | } 29 | 30 | disableReactions() { 31 | this.d.forEach(d => d()) 32 | this.d = [] 33 | } 34 | } 35 | 36 | test('Runs at expected times when responder is off or on', () => { 37 | const a = new A() 38 | expect(a.runs).toBe(0) 39 | a.enableReactions() 40 | expect(a.runs).toBe(1) 41 | a.$o = 1 42 | 43 | expect(a.runs).toBe(2) 44 | expect(a.result).toBe(1) 45 | 46 | a.disableReactions() 47 | expect(a.runs).toBe(2) 48 | 49 | a.$o = 2 50 | expect(a.runs).toBe(2) 51 | expect(a.result).toBe(1) 52 | 53 | a.$o = 3 54 | expect(a.runs).toBe(2) 55 | expect(a.result).toBe(1) 56 | 57 | a.$o = 4 58 | a.enableReactions() 59 | expect(a.runs).toBe(3) 60 | expect(a.result).toBe(4) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /src/lib/component-types/root-component.ts: -------------------------------------------------------------------------------- 1 | import {DomComponent} from './host/dom-component' 2 | import {TextComponent} from './text-component' 3 | import {Render} from '../render' 4 | import {Order} from '../util/order' 5 | import {AnyComponent} from './base-component' 6 | import {FiendElement} from '../..' 7 | import {RunStack} from '../observables/run-stack' 8 | 9 | export class RootComponent { 10 | component: AnyComponent | null = null 11 | order = '1' 12 | key = 'root' 13 | 14 | inserted: (DomComponent | TextComponent)[] = [] 15 | 16 | // key is an element, value is the previous element 17 | siblings = new WeakMap() 18 | 19 | constructor(public element: HTMLElement) {} 20 | 21 | render(tree: FiendElement) { 22 | this.component = Render.component(tree, this.component, this, this, 0) 23 | RunStack.run() 24 | } 25 | 26 | insertChild(child: DomComponent | TextComponent) { 27 | Order.insert(this, child) 28 | } 29 | 30 | removeChild(child: DomComponent | TextComponent) { 31 | Order.remove(this, child) 32 | } 33 | 34 | moveChild(child: DomComponent | TextComponent) { 35 | Order.move(this, child) 36 | } 37 | 38 | remove(): void { 39 | this.component?.remove() 40 | this.component = null 41 | } 42 | 43 | get html(): string { 44 | return this.element.innerHTML 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/observables/memory.test.ts: -------------------------------------------------------------------------------- 1 | import {$Model, makeObservable} from './$model' 2 | import {$AutoRun} from './responder' 3 | import {$Val} from './value-style' 4 | 5 | describe('memory leak test', () => { 6 | let n = 0 7 | const outer = $Val(0) 8 | 9 | class A { 10 | constructor() { 11 | makeObservable(this) 12 | 13 | $AutoRun(() => { 14 | this.$b 15 | }) 16 | } 17 | get $b() { 18 | n++ 19 | return outer() 20 | } 21 | } 22 | 23 | test('a', () => { 24 | new A() 25 | expect(n).toEqual(1) 26 | outer(outer() + 1) 27 | expect(n).toEqual(2) 28 | }) 29 | 30 | test('a2', () => { 31 | new A() 32 | expect(n).toEqual(3) 33 | outer(outer() + 1) 34 | expect(n).toEqual(5) 35 | }) 36 | }) 37 | 38 | describe('memory leak test2', () => { 39 | let n = 0 40 | const outer = $Val(0) 41 | 42 | class A extends $Model { 43 | constructor() { 44 | super() 45 | super.connect() 46 | 47 | this.$AutoRun(() => { 48 | this.$b 49 | }) 50 | } 51 | get $b() { 52 | n++ 53 | return outer() 54 | } 55 | } 56 | 57 | test('a', () => { 58 | const a = new A() 59 | expect(n).toEqual(1) 60 | outer(outer() + 1) 61 | expect(n).toEqual(2) 62 | 63 | a.disposeReactions() 64 | }) 65 | 66 | test('a2', () => { 67 | new A() 68 | expect(n).toEqual(3) 69 | outer(outer() + 1) 70 | expect(n).toEqual(4) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /src/lib/observables/$component.ts: -------------------------------------------------------------------------------- 1 | import {$AutoRun, $Reaction, PureComponent} from '../..' 2 | import {globalStack} from './global-stack' 3 | import {F0, OrderedResponder, ResponderType} from './responder' 4 | import {makeObservable} from './$model' 5 | import {RunStack} from './run-stack' 6 | 7 | export abstract class $Component

8 | extends PureComponent

9 | implements OrderedResponder 10 | { 11 | responderType = ResponderType.component as const 12 | 13 | ordered = true as const 14 | disposers: F0[] = [] 15 | 16 | mount() { 17 | makeObservable(this) 18 | 19 | this.update() 20 | RunStack.componentDidMountStack.push(this._ref) 21 | } 22 | 23 | run() { 24 | if (this._ref.current === null) { 25 | return 26 | } 27 | 28 | if (__FIEND_DEV__) { 29 | console.debug('run', this.constructor.name) 30 | } 31 | 32 | this.update() 33 | 34 | RunStack.componentDidUpdateStack.push(this._ref) 35 | } 36 | 37 | update() { 38 | globalStack.pushResponder(this) 39 | super.update() 40 | globalStack.popResponder() 41 | } 42 | 43 | remove(): void { 44 | this._ref.current = null 45 | 46 | for (const d of this.disposers) d() 47 | this.disposers = [] 48 | super.remove() 49 | } 50 | 51 | $AutoRun(f: () => void) { 52 | this.disposers.push($AutoRun(f)) 53 | } 54 | 55 | $Reaction(calc: () => T, f: (result: T) => void) { 56 | this.disposers.push($Reaction(calc, f)) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/lib/observables/computed/computed-get.test.ts: -------------------------------------------------------------------------------- 1 | import {makeObservable} from '../$model' 2 | import {Computed} from './computed' 3 | import {Atom} from '../atom' 4 | 5 | describe('Computed get behaviour', () => { 6 | test('1 computed, call get without a responder', () => { 7 | class A { 8 | declare __$o: Atom 9 | $o = 0 10 | 11 | runs = 0 12 | 13 | constructor() { 14 | makeObservable(this) 15 | } 16 | 17 | declare __$c: Computed 18 | get $c() { 19 | this.runs++ 20 | return this.$o 21 | } 22 | } 23 | 24 | const a = new A() 25 | expect(a.runs).toBe(0) 26 | 27 | // call get(null) 28 | a.$c 29 | expect(a.runs).toBe(1) 30 | 31 | a.$o = 1 32 | expect(a.runs).toBe(1) 33 | 34 | expect(a.__$c._ref.current).toBe(null) 35 | expect(a.__$o.hasActiveResponders()).toBe(false) 36 | }) 37 | 38 | test(`Computed shouldn't become active if the calling computed isn't active`, () => { 39 | class A { 40 | declare __$o: Atom 41 | $o = 0 42 | 43 | constructor() { 44 | makeObservable(this) 45 | } 46 | 47 | declare __$c: Computed 48 | get $c() { 49 | return this.$o 50 | } 51 | 52 | declare __$c2: Computed 53 | get $c2() { 54 | return this.$c 55 | } 56 | } 57 | 58 | const a = new A() 59 | 60 | expect(a.$c2).toBe(0) 61 | expect(a.__$c2._ref.current).toBe(null) 62 | expect(a.__$c._ref.current).toBe(null) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /src/lib/observables/$map.ts: -------------------------------------------------------------------------------- 1 | import {Atom} from './atom' 2 | import {globalStack} from './global-stack' 3 | 4 | export class $Map { 5 | #map: Map 6 | #size: Atom = new Atom(0, 'size') 7 | #changes: Atom = new Atom(0, 'changes') 8 | 9 | constructor() { 10 | this.#map = new Map() 11 | } 12 | 13 | clear(): void { 14 | this.#map.clear() 15 | this.#size.set(0) 16 | } 17 | 18 | delete(key: K): boolean { 19 | const deleted = this.#map.delete(key) 20 | 21 | if (deleted) { 22 | this.#size.set(this.#map.size) 23 | } 24 | 25 | return deleted 26 | } 27 | 28 | forEach(callbackfn: (value: V, key: K, map: Map) => void, thisArg?: any): void { 29 | if (this.#size.get(globalStack.getCurrentResponder()) > 0) { 30 | this.#map.forEach(callbackfn, thisArg) 31 | } 32 | } 33 | 34 | get(key: K): V | undefined { 35 | if (this.#size.get(globalStack.getCurrentResponder()) > 0) { 36 | return this.#map.get(key) 37 | } 38 | return undefined 39 | } 40 | 41 | has(key: K): boolean { 42 | return this.#size.get(globalStack.getCurrentResponder()) > 0 && this.#map.has(key) 43 | } 44 | 45 | set(key: K, value: V): this { 46 | const prevValue = this.#map.get(key) 47 | 48 | if (value !== prevValue) { 49 | this.#map.set(key, value) 50 | this.#changes.set(this.#changes.get(globalStack.getCurrentResponder()) + 1) 51 | } 52 | this.#size.set(this.#map.size) 53 | 54 | return this 55 | } 56 | 57 | get size(): number { 58 | return this.#size.get(globalStack.getCurrentResponder()) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/lib/observables/atom.ts: -------------------------------------------------------------------------------- 1 | import {Responder, UnorderedResponder} from './responder' 2 | import { 3 | addCallingResponderToOurList, 4 | hasActiveResponders, 5 | Notifier, 6 | notify, 7 | } from './notifier' 8 | import {$Component} from './$component' 9 | import {RefObject} from '../util/ref' 10 | import {Computed} from './computed/computed' 11 | 12 | export class Atom implements Notifier { 13 | computeds = new Set>>() 14 | reactions = new Set>() 15 | components = new Map>() 16 | 17 | constructor(public value: T, public name: string) {} 18 | 19 | get(responder: Responder | null): T { 20 | if (responder !== null) { 21 | this.addCallingResponderToOurList(responder) 22 | } 23 | 24 | return this.value 25 | } 26 | 27 | set(value: T) { 28 | if (this.value !== value) { 29 | this.value = value 30 | 31 | if (this.hasActiveResponders()) { 32 | notify(this) 33 | } else { 34 | this.deactivateAndClear() 35 | } 36 | } 37 | } 38 | 39 | hasActiveResponders(): boolean { 40 | return hasActiveResponders(this) 41 | } 42 | 43 | deactivateAndClear(): void { 44 | const {computeds, reactions, components} = this 45 | 46 | reactions.clear() 47 | components.clear() 48 | 49 | for (const c of computeds) { 50 | if (c.current !== null) { 51 | c.current.deactivateAndClear() 52 | } 53 | } 54 | computeds.clear() 55 | } 56 | 57 | addCallingResponderToOurList(responder: Responder): void { 58 | addCallingResponderToOurList(this, responder) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/dom-tests/test-scheduling-z.test.tsx: -------------------------------------------------------------------------------- 1 | import {render} from '..' 2 | import {screen} from '@testing-library/dom' 3 | import {$Component} from '..' 4 | import {Div} from '..' 5 | import {$Val} from '../lib/observables/value-style' 6 | 7 | export function sleep(ms: number) { 8 | return new Promise(resolve => { 9 | setTimeout(() => { 10 | resolve() 11 | }, ms) 12 | }) 13 | } 14 | 15 | // TODO: This test needs to be rewritten. 16 | 17 | xdescribe('test scheduling', () => { 18 | test('shows alternate div after state update', () => { 19 | const store = new Store() 20 | 21 | // const s = 22 | const s = A.$({store}) 23 | render(s, document.body) 24 | 25 | console.log(document.body.innerHTML) 26 | 27 | expect(screen.queryByText('a')).toBeDefined() 28 | expect(screen.queryByText('b')).toBeNull() 29 | 30 | // store.a(false) 31 | 32 | // await sleep(1) 33 | 34 | expect(screen.queryByText('b')).toBeDefined() 35 | expect(screen.queryByText('a')).toBeNull() 36 | }) 37 | }) 38 | 39 | class Store { 40 | a = $Val(2) 41 | b = $Val(3) 42 | c = $Val(4) 43 | } 44 | 45 | interface SwitcherProps { 46 | store: Store 47 | } 48 | class A extends $Component { 49 | render() { 50 | const {store} = this.props 51 | 52 | return [Div(store.a().toString()), B.$({store})] 53 | 54 | // return ( 55 | // 56 | //

57 | // 58 | // 59 | // ) 60 | } 61 | } 62 | class B extends $Component { 63 | render() { 64 | const {store} = this.props 65 | 66 | return Div(store.b().toString()) 67 | } 68 | } 69 | class C extends $Component { 70 | render() { 71 | const {store} = this.props 72 | 73 | return Div(store.c().toString()) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/lib/component-types/host/dom-components.ts: -------------------------------------------------------------------------------- 1 | import {makeHtmlElementConstructor} from './dom-component-types' 2 | 3 | export const H1 = makeHtmlElementConstructor('h1') 4 | export const H2 = makeHtmlElementConstructor('h2') 5 | export const H3 = makeHtmlElementConstructor('h3') 6 | export const Div = makeHtmlElementConstructor('div') 7 | export const Header = makeHtmlElementConstructor('header') 8 | export const Footer = makeHtmlElementConstructor('footer') 9 | export const Span = makeHtmlElementConstructor('span') 10 | export const A = makeHtmlElementConstructor('a') 11 | export const P = makeHtmlElementConstructor('p') 12 | export const B = makeHtmlElementConstructor('b') 13 | export const Br = makeHtmlElementConstructor('br') 14 | export const Img = makeHtmlElementConstructor('img') 15 | export const Ul = makeHtmlElementConstructor('ul') 16 | export const Li = makeHtmlElementConstructor('li') 17 | export const Ol = makeHtmlElementConstructor('ol') 18 | export const Video = makeHtmlElementConstructor('video') 19 | export const Source = makeHtmlElementConstructor('source') 20 | export const Idiomatic = makeHtmlElementConstructor('i') 21 | export const Button = makeHtmlElementConstructor('button') 22 | export const Canvas = makeHtmlElementConstructor('canvas') 23 | export const Form = makeHtmlElementConstructor('form') 24 | export const Input = makeHtmlElementConstructor('input') 25 | export const DataList = makeHtmlElementConstructor('datalist') 26 | export const Option = makeHtmlElementConstructor('option') 27 | export const Textarea = makeHtmlElementConstructor('textarea') 28 | export const Label = makeHtmlElementConstructor('label') 29 | 30 | export const Table = makeHtmlElementConstructor('table') 31 | export const Tr = makeHtmlElementConstructor('tr') 32 | export const Td = makeHtmlElementConstructor('td') 33 | export const Th = makeHtmlElementConstructor('th') 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FiendUI 2 | 3 | Small UI library (~15kb) heavily inspired by React and Mobx that I use in both GitFiend and the GitFiend website. 4 | 5 | After spending 100s of hours optimising React code I got tired of how difficult it was. This library makes it a lot 6 | simpler to make awesome dynamic UI. 7 | 8 | ### Features: 9 | 10 | - Familiar declarative component style 11 | - Fast and lightweight 12 | - Easy and efficient state sharing between components: 13 | Modify a variable anywhere and any component that is using it is automatically and efficiently updated. 14 | 15 | ```ts 16 | class Store { 17 | constructor() { 18 | // Converts any fields starting with '$' into observables. 19 | makeObservable(this) 20 | } 21 | 22 | // '$' as first character creates an observable. 23 | $num = 2 24 | 25 | // '$' as first character makes this getter a computed. 26 | get $square() { 27 | return this.$num ** 2 28 | } 29 | } 30 | 31 | // A $Component renders when any observable or computed it 32 | // uses from anywhere is updated. 33 | class MyComponent extends $Component<{ 34 | store: Store 35 | }> { 36 | render() { 37 | const {$num, $square} = this.props.store 38 | 39 | return Div({ 40 | className: 'MyComponent', 41 | children: [ 42 | `Square of ${$num} equals ${$square}.`, 43 | Button({children: ['increment'], onclick: this.onClick}), 44 | ], 45 | }) 46 | } 47 | 48 | onClick = () => { 49 | this.props.store.$num++ 50 | } 51 | } 52 | ``` 53 | 54 | #### Setup Root 55 | ```ts 56 | const store = new Store() 57 | 58 | render(MyComponent.$({store}), document.getElementById('root')) 59 | ``` 60 | 61 | ### Usage 62 | 63 | I currently use this by cloning this repo and importing from "index.ts". 64 | 65 | ### Development 66 | 67 | Requires a recent Node.js and Npm installed. 68 | 69 | Install dependencies: `npm i` 70 | Run tests: `npm t` 71 | -------------------------------------------------------------------------------- /src/lib/component-types/text-component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AnyComponent, 3 | ComponentBase, 4 | ComponentType, 5 | ParentComponent, 6 | } from './base-component' 7 | import {Order} from '../util/order' 8 | import {DomComponent} from './host/dom-component' 9 | import {RootComponent} from './root-component' 10 | 11 | export class TextComponent implements ComponentBase { 12 | _type = ComponentType.text as const 13 | element: Text 14 | order: string 15 | key: string 16 | 17 | constructor( 18 | public text: string, 19 | public domParent: DomComponent | RootComponent, 20 | directParent: ParentComponent, 21 | public index: number, 22 | ) { 23 | const order = Order.key(directParent.order, index) 24 | 25 | this.key = directParent.key + index 26 | this.order = order 27 | this.element = document.createTextNode(text) 28 | 29 | domParent.insertChild(this) 30 | } 31 | 32 | remove(): void { 33 | this.domParent.removeChild(this) 34 | } 35 | } 36 | 37 | export function renderTextComponent( 38 | text: string, 39 | prev: AnyComponent | null, 40 | domParent: DomComponent | RootComponent, 41 | directParent: ParentComponent, 42 | index: number, 43 | ): TextComponent { 44 | if (prev === null) { 45 | return new TextComponent(text, domParent, directParent, index) 46 | } 47 | 48 | if (prev._type === ComponentType.text) { 49 | const prevOrder = prev.order 50 | const newOrder = Order.key(directParent.order, index) 51 | 52 | if (prevOrder !== newOrder) { 53 | prev.index = index 54 | prev.order = newOrder 55 | 56 | domParent.moveChild(prev) 57 | } 58 | 59 | if (prev.text === text) { 60 | return prev 61 | } else { 62 | prev.element.nodeValue = text 63 | prev.text = text 64 | return prev 65 | } 66 | } 67 | 68 | prev.remove() 69 | return new TextComponent(text, domParent, directParent, index) 70 | } 71 | -------------------------------------------------------------------------------- /src/dom-tests/host.test.ts: -------------------------------------------------------------------------------- 1 | import {Div} from '..' 2 | import {mkRoot} from './test-helpers' 3 | 4 | describe('simple div', () => { 5 | // xtest('render host', () => { 6 | // const root = mkRoot() 7 | // 8 | // renderHost('div', {}, null, root.order, 0) 9 | // 10 | // expect(root.element.innerHTML).toEqual('
') 11 | // }) 12 | // 13 | // xtest('update div', () => { 14 | // const root = mkRoot() 15 | // 16 | // const host = renderHost('div', {}, null, root.order, 0) 17 | // 18 | // expect(root.element.innerHTML).toEqual('
') 19 | // 20 | // const host2 = renderHost>( 21 | // 'div', 22 | // {className: 'simple'}, 23 | // host, 24 | // root.order, 25 | // 0 26 | // ) 27 | // 28 | // expect(root.element.innerHTML).toEqual(`
`) 29 | // 30 | // renderHost('div', {}, host2, root.order, 0) 31 | // 32 | // expect(root.element.innerHTML).toEqual('
') 33 | // }) 34 | 35 | test('remove child on next render', () => { 36 | const root = mkRoot() 37 | 38 | const h = Div({children: [Div('a'), Div('b')]}) 39 | 40 | root.render(h) 41 | 42 | expect(root.element.innerHTML).toEqual(`
a
b
`) 43 | 44 | const h2 = Div({children: [Div('a')]}) 45 | 46 | root.render(h2) 47 | 48 | expect(root.element.innerHTML).toEqual(`
a
`) 49 | }) 50 | 51 | test('remove child on next render2', () => { 52 | const root = mkRoot() 53 | 54 | root.render(Div({children: [Div('a'), Div('b')]})) 55 | // const divs = renderTree(div({children: [div('a'), div('b')]}), null, root.order, 0) 56 | 57 | expect(root.element.innerHTML).toEqual(`
a
b
`) 58 | 59 | root.render(Div({children: [Div('a')]})) 60 | // renderTree(div({children: [div('a')]}), divs, root.order, 0) 61 | 62 | expect(root.element.innerHTML).toEqual(`
a
`) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /src/lib/util/order.test.ts: -------------------------------------------------------------------------------- 1 | import {Order} from './order' 2 | import {DomComponent} from '../component-types/host/dom-component' 3 | import {ElementNamespace} from './element' 4 | import {ElementComponent} from '../component-types/base-component' 5 | import {RunStack} from '../observables/run-stack' 6 | import {mkRoot} from '../../dom-tests/test-helpers' 7 | 8 | describe('Order.add comparisons', () => { 9 | test('1.1 < 1.2', () => { 10 | expect(Order.key('1', 1) < Order.key('1', 2)).toBe(true) 11 | }) 12 | 13 | test('1 3 < 1 20', () => { 14 | expect(Order.key('1', 3) < Order.key('1', 20)).toBe(true) 15 | }) 16 | }) 17 | 18 | describe('insert', () => { 19 | const root = mkRoot() 20 | 21 | const parent = new DomComponent('div', ElementNamespace.html, {}, root, root, 0) 22 | RunStack.run() 23 | const {inserted} = parent 24 | 25 | test('try different insert indices', () => { 26 | new DomComponent('div', ElementNamespace.html, {}, parent, parent, 3) 27 | RunStack.run() 28 | expect(inserted.map(i => i.order)).toEqual(['103']) 29 | checkOrder(inserted) 30 | 31 | new DomComponent('div', ElementNamespace.html, {}, parent, parent, 4) 32 | RunStack.run() 33 | expect(inserted.map(i => i.order)).toEqual(['103', '104']) 34 | checkOrder(inserted) 35 | 36 | new DomComponent('div', ElementNamespace.html, {}, parent, parent, 1) 37 | RunStack.run() 38 | expect(inserted.map(i => i.order)).toEqual(['101', '103', '104']) 39 | checkOrder(inserted) 40 | 41 | new DomComponent('div', ElementNamespace.html, {}, parent, parent, 2) 42 | RunStack.run() 43 | expect(inserted.map(i => i.order)).toEqual(['101', '102', '103', '104']) 44 | checkOrder(inserted) 45 | }) 46 | }) 47 | 48 | export function checkOrder(inserted: ElementComponent[]) { 49 | let prev: ElementComponent | null = null 50 | 51 | for (const c of inserted) { 52 | if (prev !== null) { 53 | expect(prev.element).toBe(c.element.previousElementSibling) 54 | } 55 | prev = c 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/lib/util/style.test.ts: -------------------------------------------------------------------------------- 1 | import {c, s} from './style' 2 | 3 | describe('s - tagged style string template function', () => { 4 | const text = 'width: 50px; height: 2px;' 5 | const style = s`width: 50px; height: 2px;` 6 | 7 | test(text, () => { 8 | expect(style).toEqual(text) 9 | }) 10 | 11 | test('empty string', () => { 12 | expect(s``).toEqual('') 13 | }) 14 | 15 | test('templates', () => { 16 | expect(s`height: ${50}px; width: ${30}px`).toEqual('height: 50px; width: 30px') 17 | }) 18 | }) 19 | 20 | describe('class merging', () => { 21 | test('generates expected class string when given bool flags', () => { 22 | expect(c`CloneDialog ${true}`).toEqual('CloneDialog') 23 | expect(c`CloneDialog ${false}`).toEqual('') 24 | 25 | expect(c`omg ${true} nah ${false}`).toEqual('omg') 26 | expect(c`omg ${false} nah ${true}`).toEqual('nah') 27 | expect(c`omg ${false} nah ${false}`).toEqual('') 28 | expect(c`omg`).toEqual('omg') 29 | expect(c`omg nah`).toEqual('omg nah') 30 | }) 31 | 32 | xtest('compare speed', () => { 33 | const num = 1000000 34 | 35 | let s: string 36 | 37 | console.time('plain') 38 | for (let i = 0; i < num; i++) { 39 | s = `omg` 40 | s = `omg2` 41 | } 42 | console.timeEnd('plain') 43 | 44 | console.time('c') 45 | for (let i = 0; i < num; i++) { 46 | s = c`omg ${true} nah ${false} blah ${false}` 47 | s = c`omg2` 48 | } 49 | console.timeEnd('c') 50 | 51 | console.time('mc') 52 | for (let i = 0; i < num; i++) { 53 | s = mc('omg', { 54 | nah: false, 55 | blah: false, 56 | }) 57 | s = mc('omg2', {}) 58 | } 59 | console.timeEnd('mc') 60 | }) 61 | }) 62 | 63 | function mc(initialClass: string, mapping: Record): string { 64 | let classes = '' 65 | 66 | for (const key in mapping) { 67 | if (mapping.hasOwnProperty(key) && mapping[key] === true) { 68 | classes += ' ' + key 69 | } 70 | } 71 | 72 | return initialClass + classes 73 | } 74 | -------------------------------------------------------------------------------- /src/lib/component-types/host/dom-component-types.ts: -------------------------------------------------------------------------------- 1 | import {RefObject} from '../../util/ref' 2 | import {FiendNode} from '../../..' 3 | import {ElementNamespace, ElementType, HostElement} from '../../util/element' 4 | 5 | type DataPropertyNames = { 6 | [K in keyof T]: T[K] extends Function ? never : K 7 | }[keyof T] 8 | type OptionalDataPropertiesOnly = { 9 | [P in DataPropertyNames]?: T[P] 10 | } 11 | 12 | export type ElementNameMap = SVGElementTagNameMap & HTMLElementTagNameMap 13 | 14 | export type HostAttributes = Omit< 15 | OptionalDataPropertiesOnly, 16 | 'style' | 'children' 17 | > & { 18 | key?: string 19 | style?: string 20 | ref?: RefObject 21 | 'aria-labelledby'?: string 22 | 'aria-describedby'?: string 23 | role?: 'tab' | 'dialog' | 'checkbox' | 'button' 24 | children?: FiendNode[] 25 | } 26 | 27 | export type SvgElementAttributes = Omit< 28 | OptionalDataPropertiesOnly, 29 | 'style' | 'children' 30 | > & { 31 | key?: string 32 | style?: string 33 | ref?: RefObject 34 | children?: FiendNode[] 35 | } 36 | 37 | export function makeHtmlElementConstructor( 38 | tagName: N, 39 | ): (props?: HostAttributes | string) => HostElement { 40 | return props => { 41 | if (props === undefined) 42 | return { 43 | elementType: ElementType.dom, 44 | _type: tagName, 45 | namespace: ElementNamespace.html, 46 | props: {}, 47 | } 48 | 49 | if (typeof props === 'string') 50 | return { 51 | elementType: ElementType.dom, 52 | _type: tagName, 53 | namespace: ElementNamespace.html, 54 | props: { 55 | children: [props], 56 | }, 57 | } 58 | 59 | return { 60 | elementType: ElementType.dom, 61 | _type: tagName, 62 | namespace: ElementNamespace.html, 63 | props, 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/demos/lots-of-elements.tsx: -------------------------------------------------------------------------------- 1 | // import {createElement} from '../lib/create-element' 2 | // import {render} from '../lib/render' 3 | // 4 | // export function lotsOfElements(root: HTMLElement): void { 5 | // console.time('render') 6 | // 7 | // render( 8 | //
14 | //

Hello

15 | // Some Text 16 | //
17 | //
a
18 | //
b
19 | //
c
20 | //
a
21 | //
b
22 | //
c
23 | //
a
24 | //
b
25 | //
c
26 | //
32 | //

Hello

33 | // Some Text 34 | //
35 | //
a
36 | //
b
37 | //
c
38 | //
a
39 | //
b
40 | //
c
41 | //
a
42 | //
b
43 | //
c
44 | //
50 | //

Hello

51 | // Some Text 52 | //
53 | //
a
54 | //
b
55 | //
c
56 | //
a
57 | //
b
58 | //
c
59 | //
a
60 | //
b
61 | //
c
62 | //
63 | //
64 | //
65 | //
66 | //
67 | //
, 68 | // root 69 | // ) 70 | // 71 | // console.timeEnd('render') 72 | // } 73 | -------------------------------------------------------------------------------- /src/lib/render.test.ts: -------------------------------------------------------------------------------- 1 | import {Div} from './component-types/host/dom-components' 2 | import {render} from './render' 3 | import {$Component} from './observables/$component' 4 | import {$Model} from './observables/$model' 5 | import {PureComponent} from './component-types/pure-component' 6 | import {FiendNode} from './util/element' 7 | 8 | class F extends PureComponent { 9 | render() { 10 | return this.props.children ?? [] 11 | } 12 | } 13 | 14 | function frag(...children: FiendNode[]) { 15 | return F.$({children}) 16 | } 17 | 18 | describe('render', () => { 19 | test('repeat render', () => { 20 | const el = document.createElement('div') 21 | 22 | let t = frag(Div('a'), Div('b'), Div('c')) 23 | 24 | render(t, el) 25 | 26 | expect(el.innerHTML).toEqual('
a
b
c
') 27 | 28 | t = frag(Div('a'), Div('b'), Div('c'), Div('d')) 29 | 30 | render(t, el) 31 | 32 | expect(el.innerHTML).toEqual('
a
b
c
d
') 33 | 34 | t = frag(Div('d'), Div('a'), Div('b'), Div('c')) 35 | 36 | render(t, el) 37 | 38 | expect(el.innerHTML).toEqual('
d
a
b
c
') 39 | }) 40 | 41 | test('update observables', () => { 42 | class S extends $Model { 43 | $a = true 44 | 45 | constructor() { 46 | super() 47 | super.connect() 48 | } 49 | } 50 | 51 | class A extends PureComponent { 52 | render() { 53 | return Div('a') 54 | } 55 | } 56 | class B extends PureComponent { 57 | render() { 58 | return Div('b') 59 | } 60 | } 61 | 62 | class C extends $Component<{store: S}> { 63 | render() { 64 | if (this.props.store.$a) return A.$({}) 65 | return B.$({}) 66 | } 67 | } 68 | 69 | const el = document.createElement('div') 70 | 71 | const s = new S() 72 | render(C.$({store: s}), el) 73 | expect(el.innerHTML).toEqual('
a
') 74 | 75 | s.$a = false 76 | expect(el.innerHTML).toEqual('
b
') 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /src/demos/boxes.ts: -------------------------------------------------------------------------------- 1 | import {PureComponent} from '../lib/component-types/pure-component' 2 | import {render} from '../lib/render' 3 | import {Div} from '../lib/component-types/host/dom-components' 4 | import {s} from '../lib/util/style' 5 | import {FiendElement} from '../lib/util/element' 6 | 7 | const boxHeight = 14 8 | 9 | interface BoxesProps { 10 | width: number 11 | height: number 12 | } 13 | 14 | export class Boxes extends PureComponent { 15 | render() { 16 | const t = ((Date.now() - this.tick) / 20) % boxHeight 17 | 18 | const {width, height} = this.props 19 | 20 | return Div({children: this.drawBoxes(width, height, -t)}) 21 | } 22 | 23 | drawBoxes(width: number, height: number, top: number) { 24 | const numBoxes = Math.ceil(height / boxHeight) + 1 25 | const left = width * 0.2 26 | const boxWidth = width * 0.6 27 | 28 | const boxes: FiendElement[] = Array(numBoxes) 29 | 30 | for (let i = 0; i < numBoxes; i++) { 31 | boxes[i] = this.drawBox(left, top + i * boxHeight, boxWidth, boxHeight) 32 | } 33 | 34 | return boxes 35 | } 36 | 37 | drawBox(x: number, y: number, w: number, h: number): FiendElement { 38 | return Div({ 39 | style: s` 40 | position: absolute; 41 | left: ${x}px; 42 | top: ${y}px; 43 | width: ${w}px; 44 | height: ${h}px; 45 | border: 1px solid; 46 | display: flex; 47 | align-items: center; 48 | padding-left: 5px; 49 | font-size: 8px; 50 | `, 51 | children: [`I am a commit message. Here is some text.`], 52 | }) 53 | } 54 | 55 | componentDidMount(): void { 56 | const {width, height} = this.props 57 | 58 | this.loopBoxes(width, height) 59 | } 60 | 61 | tick = Date.now() 62 | 63 | loopBoxes(width: number, height: number) { 64 | this.update() 65 | 66 | requestAnimationFrame(() => { 67 | this.loopBoxes(width, height) 68 | }) 69 | } 70 | } 71 | 72 | export function boxesTest() { 73 | console.time('render') 74 | 75 | render(Boxes.$({width: window.innerWidth, height: window.innerHeight}), document.body) 76 | 77 | console.timeEnd('render') 78 | } 79 | -------------------------------------------------------------------------------- /.idea/watcherTasks.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | 24 | 25 | 36 | 44 | 45 | -------------------------------------------------------------------------------- /src/lib/component-types/fragment.test.ts: -------------------------------------------------------------------------------- 1 | import {$F} from './fragment' 2 | import {Div} from './host/dom-components' 3 | import {$Component} from '../..' 4 | import {makeObservable} from '../..' 5 | import {mkRoot} from '../../dom-tests/test-helpers' 6 | 7 | describe('fragment', () => { 8 | test('one host child', () => { 9 | const root = mkRoot() 10 | 11 | const t = $F(Div('omg')) 12 | 13 | root.render(t) 14 | 15 | expect(root.element.innerHTML).toEqual('
omg
') 16 | }) 17 | 18 | test('custom component child', () => { 19 | class C extends $Component { 20 | render() { 21 | return Div('omg') 22 | } 23 | } 24 | 25 | const root = mkRoot() 26 | root.render(C.$({})) 27 | 28 | expect(root.element.innerHTML).toEqual('
omg
') 29 | }) 30 | 31 | test('multiple children', () => { 32 | const root = mkRoot() 33 | 34 | let t = $F(Div('a'), Div('b'), Div('c')) 35 | 36 | root.render(t) 37 | 38 | expect(root.element.innerHTML).toEqual('
a
b
c
') 39 | 40 | t = $F(Div('a'), Div('b'), Div('c'), Div('d')) 41 | 42 | root.render(t) 43 | 44 | expect(root.element.innerHTML).toEqual( 45 | '
a
b
c
d
', 46 | ) 47 | 48 | t = $F(Div('d'), Div('a'), Div('b'), Div('c')) 49 | 50 | root.render(t) 51 | 52 | expect(root.element.innerHTML).toEqual( 53 | '
d
a
b
c
', 54 | ) 55 | }) 56 | 57 | test('custom component with fragment', () => { 58 | class S { 59 | $text = 'text1' 60 | 61 | constructor() { 62 | makeObservable(this) 63 | } 64 | } 65 | 66 | class C extends $Component<{store: S}> { 67 | render() { 68 | return Div({children: [this.props.store.$text], key: 'container'}) 69 | } 70 | } 71 | const root = mkRoot() 72 | 73 | const s = new S() 74 | 75 | root.render(C.$({store: s})) 76 | expect(root.element.innerHTML).toEqual('
text1
') 77 | 78 | s.$text = 'text2' 79 | expect(root.element.innerHTML).toEqual('
text2
') 80 | 81 | s.$text = 'text3' 82 | expect(root.element.innerHTML).toEqual('
text3
') 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /src/lib/observables/notifier.ts: -------------------------------------------------------------------------------- 1 | import {globalStack} from './global-stack' 2 | import {Responder, ResponderType, UnorderedResponder} from './responder' 3 | import {$Component} from './$component' 4 | import {RunStack} from './run-stack' 5 | import {RefObject} from '../util/ref' 6 | import {Computed} from './computed/computed' 7 | 8 | /* 9 | A Notifier is something with observable-like behaviour. 10 | 11 | Could be a plain observable or a computed (Computeds are both Notifiers and Responders). 12 | 13 | */ 14 | export interface Notifier { 15 | computeds: Set>> 16 | reactions: Set> 17 | components: Map> 18 | } 19 | 20 | export function addCallingResponderToOurList( 21 | notifier: Notifier, 22 | responder: Responder 23 | ): void { 24 | switch (responder.responderType) { 25 | case ResponderType.computed: 26 | notifier.computeds.add(responder._ref) 27 | break 28 | case ResponderType.autoRun: 29 | case ResponderType.reaction: 30 | notifier.reactions.add(responder._ref) 31 | break 32 | case ResponderType.component: 33 | // TODO: Improve types. 34 | const r = responder as $Component 35 | notifier.components.set(r.order, r._ref) 36 | break 37 | } 38 | } 39 | 40 | export function notify(notifier: Notifier): void { 41 | const {computeds, reactions, components} = notifier 42 | 43 | if (computeds.size > 0 || reactions.size > 0 || components.size > 0) { 44 | if (!globalStack.queueNotifierIfInAction(notifier)) { 45 | notifier.computeds = new Set() 46 | notifier.reactions = new Set() 47 | notifier.components = new Map() 48 | 49 | RunStack.runResponders(computeds, reactions, components) 50 | } 51 | } 52 | } 53 | 54 | export function hasActiveResponders({ 55 | reactions, 56 | components, 57 | computeds, 58 | }: Notifier): boolean { 59 | for (const r of reactions) if (r.current !== null) return true 60 | for (const c of components.values()) if (c.current !== null) return true 61 | for (const r of computeds) 62 | if (r.current !== null) { 63 | if (hasActiveResponders(r.current)) { 64 | return true 65 | } 66 | } 67 | 68 | return false 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/component-types/host/svg-components.ts: -------------------------------------------------------------------------------- 1 | import {FiendElement, FiendNode, StandardProps, SvgElementAttributes} from '../../..' 2 | import {ElementNamespace, ElementType, SvgElement} from '../../util/element' 3 | 4 | /** @deprecated as this approach doesn't work well for Svg */ 5 | export function makeSvgElementConstructor( 6 | tagName: N, 7 | ): (props: SvgElementAttributes) => SvgElement { 8 | return props => { 9 | return { 10 | elementType: ElementType.dom, 11 | _type: tagName, 12 | namespace: ElementNamespace.svg, 13 | props, 14 | } 15 | } 16 | } 17 | 18 | export function makeSvgElementConstructor2( 19 | tagName: keyof SVGElementTagNameMap, 20 | ): (props: Props) => SvgElement { 21 | return props => { 22 | return { 23 | elementType: ElementType.dom, 24 | _type: tagName, 25 | namespace: ElementNamespace.svg, 26 | props, 27 | } 28 | } 29 | } 30 | 31 | export type SvgAttributes = Omit, 'width' | 'height'> & { 32 | width?: number 33 | height?: number 34 | } 35 | export type PolyLineAttributes = Omit, 'points'> & { 36 | points?: string 37 | strokeLinejoin?: string 38 | stroke?: string 39 | strokeWidth?: string 40 | fill?: string 41 | } 42 | export type PolygonAttributes = Omit, 'points'> & { 43 | points?: string 44 | strokeLinejoin?: string 45 | stroke?: string 46 | strokeWidth?: string 47 | fill?: string 48 | } 49 | 50 | export const Svg = makeSvgElementConstructor('svg') as ( 51 | ...args: [SvgAttributes] | FiendNode[] 52 | ) => FiendElement 53 | export const Polyline = makeSvgElementConstructor('polyline') as ( 54 | ...args: [PolyLineAttributes] | FiendNode[] 55 | ) => FiendElement 56 | export const Polygon = makeSvgElementConstructor('polygon') as ( 57 | ...args: [PolygonAttributes] | FiendNode[] 58 | ) => FiendElement 59 | export const Circle = makeSvgElementConstructor('circle') as any 60 | export const Title = makeSvgElementConstructor('title') 61 | export const G = makeSvgElementConstructor('g') 62 | 63 | export const Line = makeSvgElementConstructor2<{ 64 | x1?: number 65 | x2?: number 66 | y1?: number 67 | y2?: number 68 | stroke?: string 69 | style?: string 70 | }>('line') 71 | -------------------------------------------------------------------------------- /src/lib/observables/global-stack.ts: -------------------------------------------------------------------------------- 1 | import {Responder, ResponderType} from './responder' 2 | import {Notifier} from './notifier' 3 | import {ActionState} from './action' 4 | import {Computed} from './computed/computed' 5 | import {RefObject} from '../util/ref' 6 | 7 | export class GlobalStack { 8 | private responderStack: Responder[] = [] 9 | private actionStack: ActionState | null = null 10 | private actionDepth = 0 11 | 12 | /* 13 | We put a responder on a stack so that notifiers can register themselves 14 | with the current responder. 15 | */ 16 | pushResponder(responder: Responder): void { 17 | this.responderStack.push(responder) 18 | } 19 | 20 | popResponder(): void { 21 | this.responderStack.pop() 22 | } 23 | 24 | getCurrentResponder(): Responder | null { 25 | const len = this.responderStack.length 26 | 27 | if (len > 0) { 28 | return this.responderStack[len - 1] 29 | } 30 | return null 31 | } 32 | 33 | queueNotifierIfInAction(notifier: Notifier): boolean { 34 | if (this.actionStack !== null) { 35 | this.actionStack.add(notifier) 36 | 37 | return true 38 | } 39 | return false 40 | } 41 | 42 | // "Inside reactive context" 43 | insideNonComputedResponder(): boolean { 44 | return this.responderStack.some(r => r.responderType !== ResponderType.computed) 45 | } 46 | 47 | /* 48 | We don't wait till the end of an action before running a computed if accessed as 49 | computeds need to always return the correct value. 50 | */ 51 | runComputedNowIfInActionStack(computed: RefObject>): boolean { 52 | if (this.actionStack?.computeds.delete(computed) === true) { 53 | computed.current?.run() 54 | return true 55 | } 56 | return false 57 | } 58 | 59 | startAction(): void { 60 | this.actionDepth++ 61 | 62 | if (this.actionStack === null) { 63 | this.actionStack = new ActionState(this.getCurrentResponder()) 64 | } else { 65 | this.actionStack.runningResponder = this.getCurrentResponder() 66 | } 67 | } 68 | 69 | endAction(): void { 70 | this.actionDepth-- 71 | 72 | if (this.actionDepth === 0) { 73 | if (this.actionStack !== null) { 74 | const action = this.actionStack 75 | this.actionStack = null 76 | action.run() 77 | } 78 | } 79 | } 80 | } 81 | 82 | export const globalStack = new GlobalStack() 83 | -------------------------------------------------------------------------------- /src/lib/observables/computed/computed-behaviour.md: -------------------------------------------------------------------------------- 1 | # Computed Behaviour 2 | #### TODO: Convert these scenarios into unit tests. 3 | 4 | A computed can be notified by (via run()) 5 | - Observable 6 | - Another Computed 7 | 8 | A Computed can notify 9 | - AutoRun 10 | - Reaction 11 | - $Component 12 | 13 | Confusing: Called by Responder vs dependencies to notify. 14 | 15 | Imagine: 16 | - A computed contains an observable. 17 | - It was created inside a $Component 18 | - The observable is updated 19 | - This notifies the computed outside a Responder 20 | - The computed calls run: 21 | - Notifies the $Component 22 | - $Component calls its run. $Component requests latest value (get) from Computed. 23 | - Computed is given a responder this time. 24 | - The $Component is unmounted, setting its own _ref to null. The computed has the null ref in it's notify list 25 | - The computed no longer has non-null responders to notify. 26 | - We should clear our list next time we run and set our _ref to null if we have none. 27 | 28 | ## Called outside/without a Responder: 29 | 30 | ### run() 31 | `run()` can only be called if _ref is not null. 32 | 33 | If a Computed is called outside a responder it should run because the observable 34 | that called it is allowed to be outside a responder. 35 | 36 | Responders don't get added to this computed as part of run(). We could 37 | get added to the calling observable or computed. We shouldn't if we have no 38 | responders. 39 | 40 | ### get(null) 41 | get is typically called by another computed, a reaction or $Component. 42 | 43 | Example: 44 | We call a computed inside the body of a $Reaction. 45 | 46 | (TODO: Check this example, seems wrong) 47 | Imagine an onmouseover event; a computed is then called outside a responder via `get(null)`. 48 | E.g. onmouseover -> set $hoverIndex -> notify $hovering computed -> draw 49 | 50 | If we are dirty (run() queued in action), then rerun. Notify responders. 51 | if _ref is null we don't know whether we are dirty because `run()` can only be called 52 | (by a notifier) if _ref is not null! (we have been turned off after all) 53 | This means we also have to rerun. We should have no responders? 54 | 55 | If get is called outside a responder, then no responders will be added to our list. 56 | Do we have any responders? If not, then _ref should be null. 57 | 58 | ## Call Computed.get(responder) when computed._ref is null 59 | 60 | ### If the responder is active (reaction or a computed with a reaction dependency): 61 | We haven't been listening for changes, so must call f(). We have a new responder 62 | to add to our list. 63 | _ref is no longer null 64 | 65 | ### We are called by a computed with null _ref 66 | We must call f(). _ref is still null. Warn about full recompute? 67 | -------------------------------------------------------------------------------- /src/lib/observables/action.test.ts: -------------------------------------------------------------------------------- 1 | import {$Action, $AsyncAction, $RunInAction} from './action' 2 | import {$AutoRun} from './responder' 3 | import {sleep} from '../../dom-tests/simple-switcher-z.test' 4 | import {$Calc, $Val} from './value-style' 5 | 6 | describe('action', () => { 7 | test('action batches updates', () => { 8 | const a = $Val(1) 9 | let count = 0 10 | 11 | $AutoRun(() => { 12 | count++ 13 | a() 14 | }) 15 | 16 | $RunInAction(() => { 17 | a(2) 18 | a(3) 19 | a(4) 20 | }) 21 | 22 | expect(count).toEqual(2) 23 | }) 24 | 25 | test('autorun supports setting', () => { 26 | const a = $Val(1) 27 | const b = $Val(2) 28 | let count = 0 29 | 30 | $AutoRun(() => { 31 | count++ 32 | b(a() * 5) 33 | }) 34 | 35 | expect(count).toEqual(1) 36 | }) 37 | 38 | test('autoRun with computed inside behaves', () => { 39 | const a = $Val(2) 40 | const b = $Val(2) 41 | const c = $Calc(() => a() * b()) 42 | let d: number = c() 43 | 44 | expect(c()).toEqual(4) 45 | 46 | let count = 0 47 | 48 | $AutoRun(() => { 49 | count++ 50 | b(a() * 5) 51 | 52 | expect(b()).toEqual(10) 53 | 54 | // Computed needs to update now. 55 | expect(c()).toEqual(20) 56 | 57 | d = c() 58 | }) 59 | 60 | expect(count).toEqual(1) 61 | }) 62 | }) 63 | 64 | describe('async action behaviour', () => { 65 | test('simple case', async () => { 66 | class Actions { 67 | num = $Val(1) 68 | 69 | updates = 0 70 | 71 | constructor() { 72 | $AutoRun(() => { 73 | this.num() 74 | this.updates++ 75 | }) 76 | } 77 | 78 | run = $Action(() => { 79 | this.num(2) 80 | this.num(3) 81 | this.num(4) 82 | }) 83 | 84 | // @ts-expect-error 85 | runAsync = $Action(async () => { 86 | this.num(2) 87 | await sleep(1) 88 | this.num(3) 89 | await sleep(1) 90 | this.num(4) 91 | }) 92 | 93 | runAsync2 = $AsyncAction(async () => { 94 | this.num(2) 95 | await sleep(1) 96 | this.num(3) 97 | await sleep(1) 98 | this.num(4) 99 | }) 100 | } 101 | 102 | const a = new Actions() 103 | 104 | await a.runAsync() 105 | 106 | expect(a.num()).toEqual(4) 107 | expect(a.updates).toEqual(4) 108 | 109 | a.num(1) 110 | a.run() 111 | 112 | expect(a.num()).toEqual(4) 113 | expect(a.updates).toEqual(6) 114 | 115 | a.num(1) 116 | await a.runAsync2() 117 | 118 | expect(a.num()).toEqual(4) 119 | expect(a.updates).toEqual(8) 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /src/lib/observables/observable.test.ts: -------------------------------------------------------------------------------- 1 | import {$AutoRun} from './responder' 2 | import {$RunInAction} from './action' 3 | import {makeObservable} from './$model' 4 | import {$Calc, $Val} from './value-style' 5 | 6 | describe('observables', () => { 7 | test('autorun', () => { 8 | let count = 0 9 | const a = $Val(5) 10 | 11 | $AutoRun(() => { 12 | count++ 13 | a() 14 | }) 15 | 16 | a(6) 17 | expect(a()).toEqual(6) 18 | expect(count).toEqual(2) 19 | 20 | a(7) 21 | expect(a()).toEqual(7) 22 | expect(count).toEqual(3) 23 | }) 24 | 25 | test('computed', () => { 26 | let count = 0 27 | const a = $Val(5) 28 | 29 | const c = $Calc(() => { 30 | count++ 31 | return a() * 3 32 | }) 33 | 34 | a(5) 35 | a(5) 36 | 37 | expect(c()).toEqual(15) 38 | expect(count).toEqual(1) 39 | 40 | a(6) 41 | expect(c()).toEqual(18) 42 | expect(count).toEqual(2) 43 | }) 44 | 45 | test('nested computed', () => { 46 | let count1 = 0 47 | let count2 = 0 48 | 49 | const a = $Val(5) 50 | 51 | const c = $Calc(() => { 52 | count1++ 53 | return a() * 3 54 | }) 55 | 56 | const c2 = $Calc(() => { 57 | count2++ 58 | return c() + 2 59 | }) 60 | 61 | expect(c2()).toEqual(17) 62 | 63 | a(4) 64 | 65 | expect(c()).toEqual(12) 66 | expect(c2()).toEqual(14) 67 | expect(count2).toEqual(2) 68 | 69 | a(6) 70 | expect(c2()).toEqual(20) 71 | expect(count2).toEqual(3) 72 | }) 73 | 74 | /* 75 | 76 | I think computeds need to keep their own queue and run when called upon? 77 | 78 | They are different from reactions. 79 | 80 | */ 81 | test('computeds in actions', () => { 82 | let count = 0 83 | 84 | const a = $Val(2) 85 | const c = $Calc(() => { 86 | count++ 87 | return a() + 1 88 | }) 89 | 90 | expect(count).toEqual(0) 91 | c() 92 | expect(count).toEqual(1) 93 | 94 | $RunInAction(() => { 95 | a(3) 96 | expect(a()).toEqual(3) 97 | expect(c()).toEqual(4) 98 | }) 99 | 100 | expect(count).toEqual(2) 101 | }) 102 | 103 | test('computeds in actions 2', () => { 104 | class A { 105 | count = 0 106 | 107 | $a = 2 108 | 109 | get $c() { 110 | this.count++ 111 | return this.$a + 1 112 | } 113 | 114 | constructor() { 115 | makeObservable(this) 116 | } 117 | } 118 | 119 | const a = new A() 120 | 121 | expect(a.count).toEqual(0) 122 | 123 | a.$c 124 | 125 | $RunInAction(() => { 126 | a.$a = 3 127 | 128 | expect(a.$a).toEqual(3) 129 | expect(a.count).toEqual(1) 130 | 131 | expect(a.$c).toEqual(4) 132 | expect(a.count).toEqual(2) 133 | }) 134 | }) 135 | }) 136 | -------------------------------------------------------------------------------- /src/lib/observables/responder.ts: -------------------------------------------------------------------------------- 1 | import {globalStack} from './global-stack' 2 | import {$Component} from './$component' 3 | import {RefObject} from '../util/ref' 4 | import {Computed} from './computed/computed' 5 | 6 | /* 7 | A Responder is an object that listens accesses to notifiers (observables). When these 8 | notifiers change, the responder is run. 9 | 10 | Could be a Computed or a reaction such as AutoRun. 11 | 12 | */ 13 | 14 | export enum ResponderType { 15 | computed, 16 | autoRun, 17 | reaction, 18 | component, 19 | } 20 | 21 | export type Responder = $Component | Computed | AutoRun | Reaction 22 | 23 | export interface OrderedResponder { 24 | responderType: ResponderType 25 | ordered: true 26 | order: string 27 | run(): void 28 | _ref: RefObject<$Component> 29 | } 30 | 31 | export interface UnorderedResponder { 32 | responderType: ResponderType 33 | ordered: false 34 | run(): void 35 | _ref: RefObject 36 | } 37 | 38 | export type F0 = () => void 39 | 40 | class AutoRun implements UnorderedResponder { 41 | responderType = ResponderType.autoRun as const 42 | ordered = false as const 43 | 44 | _ref: RefObject = { 45 | current: this, 46 | } 47 | 48 | constructor(public f: () => void) { 49 | this.run() 50 | } 51 | 52 | run() { 53 | if (this._ref.current === null) return 54 | 55 | globalStack.pushResponder(this) 56 | this.f() 57 | globalStack.popResponder() 58 | } 59 | 60 | end: F0 = () => { 61 | this._ref.current = null 62 | } 63 | } 64 | 65 | export function $AutoRun(f: () => void): F0 { 66 | return new AutoRun(f).end 67 | } 68 | 69 | class Reaction implements UnorderedResponder { 70 | responderType = ResponderType.reaction as const 71 | ordered = false as const 72 | value: T 73 | 74 | _ref: RefObject = { 75 | current: this, 76 | } 77 | 78 | constructor(private calc: () => T, private f: (result: T) => void) { 79 | globalStack.pushResponder(this) 80 | this.value = this.calc() 81 | globalStack.popResponder() 82 | } 83 | 84 | run(): void { 85 | if (this._ref.current === null) return 86 | 87 | globalStack.pushResponder(this) 88 | const value = this.calc() 89 | globalStack.popResponder() 90 | 91 | if (this.value !== value) { 92 | this.value = value 93 | 94 | this.f(value) 95 | } 96 | } 97 | 98 | end: F0 = () => { 99 | this._ref.current = null 100 | // this._ref = {current: null} 101 | } 102 | } 103 | 104 | /* 105 | A Reaction will keep going so long as the observables inside it's function still exist. 106 | 107 | If a fiend class has a shorter lifetime than observables it depends on, they should be cleaned up. 108 | */ 109 | export function $Reaction(calc: () => T, f: (result: T) => void): F0 { 110 | return new Reaction(calc, f).end 111 | } 112 | -------------------------------------------------------------------------------- /src/lib/observables/computed/computed.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addCallingResponderToOurList, 3 | hasActiveResponders, 4 | Notifier, 5 | notify, 6 | } from '../notifier' 7 | import {globalStack} from '../global-stack' 8 | import {Responder, ResponderType, UnorderedResponder} from '../responder' 9 | import {$Component} from '../$component' 10 | import {RefObject} from '../../util/ref' 11 | 12 | export class Computed implements UnorderedResponder, Notifier { 13 | responderType = ResponderType.computed as const 14 | ordered = false as const 15 | 16 | // Things we call when this computed updates. 17 | computeds = new Set>>() 18 | reactions = new Set>() 19 | components = new Map>() 20 | 21 | value: T | unknown 22 | 23 | _ref: RefObject = { 24 | current: null, 25 | } 26 | 27 | constructor(public f: () => T, public name: string) {} 28 | 29 | run(): void { 30 | const active = this.hasActiveResponders() 31 | 32 | if (active) { 33 | this.runFunction(true) 34 | } else if (this._ref.current !== null) { 35 | this.deactivateAndClear() 36 | } 37 | } 38 | 39 | get(responder: Responder | null): T | unknown { 40 | if (responder !== null) { 41 | this.addCallingResponderToOurList(responder) 42 | } 43 | 44 | const previouslyDeactivated = this._ref.current === null 45 | const active = this.hasActiveResponders() 46 | 47 | if (previouslyDeactivated) { 48 | if (active) { 49 | this._ref.current = this 50 | } 51 | this.runFunction(false) 52 | } else { 53 | if (active) { 54 | globalStack.runComputedNowIfInActionStack(this._ref) 55 | } else { 56 | this.deactivateAndClear() 57 | } 58 | } 59 | 60 | return this.value 61 | } 62 | 63 | runFunction(shouldNotify: boolean) { 64 | globalStack.pushResponder(this) 65 | const value = this.f() 66 | globalStack.popResponder() 67 | 68 | if (value !== this.value) { 69 | this.value = value 70 | 71 | if (shouldNotify) { 72 | notify(this) 73 | } 74 | } 75 | } 76 | 77 | addCallingResponderToOurList(responder: Responder) { 78 | addCallingResponderToOurList(this, responder) 79 | } 80 | 81 | hasActiveResponders(): boolean { 82 | return hasActiveResponders(this) 83 | } 84 | 85 | deactivateAndClear(): void { 86 | this._ref.current = null 87 | 88 | const {computeds, reactions, components} = this 89 | 90 | reactions.clear() 91 | components.clear() 92 | 93 | for (const c of computeds) { 94 | if (c.current !== null) { 95 | c.current.deactivateAndClear() 96 | } 97 | } 98 | computeds.clear() 99 | } 100 | 101 | isMarkedActive(): boolean { 102 | return this._ref.current !== null 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/demos/canvas.ts: -------------------------------------------------------------------------------- 1 | import {PureComponent} from '../lib/component-types/pure-component' 2 | import {createRef} from '../lib/util/ref' 3 | import {render} from '../lib/render' 4 | import {Canvas} from '../lib/component-types/host/dom-components' 5 | import {s} from '../lib/util/style' 6 | 7 | interface TextCanvasProps { 8 | width: number 9 | height: number 10 | } 11 | 12 | const boxHeight = 30 13 | 14 | export class TextCanvas extends PureComponent { 15 | ref = createRef() 16 | 17 | render() { 18 | const {width, height} = this.props 19 | 20 | return Canvas({ref: this.ref, style: s`width: ${width}px; height: ${height}px;`}) 21 | } 22 | 23 | componentDidMount(): void { 24 | const el = this.ref.current 25 | 26 | if (el !== null) { 27 | const {width, height} = this.props 28 | 29 | const context = getCanvasContext(el, width, height) 30 | 31 | if (context !== null) this.drawStuff(context, width, height) 32 | } 33 | } 34 | 35 | tick = Date.now() 36 | 37 | drawStuff(ctx: CanvasRenderingContext2D, width: number, height: number) { 38 | const t = ((Date.now() - this.tick) / 6) % boxHeight 39 | 40 | drawBoxes(ctx, width, height, -t) 41 | 42 | requestAnimationFrame(() => { 43 | this.drawStuff(ctx, width, height) 44 | }) 45 | } 46 | } 47 | 48 | function drawBoxes( 49 | ctx: CanvasRenderingContext2D, 50 | width: number, 51 | height: number, 52 | top: number, 53 | ) { 54 | // console.time('draw boxes') 55 | ctx.clearRect(0, 0, width, height) 56 | ctx.beginPath() 57 | 58 | const numBoxes = Math.ceil(height / boxHeight) + 1 59 | const left = width * 0.2 60 | const boxWidth = width * 0.6 61 | 62 | for (let i = 0; i < numBoxes; i++) { 63 | drawBox(ctx, left, top + i * boxHeight, boxWidth, boxHeight) 64 | } 65 | 66 | ctx.stroke() 67 | // console.timeEnd('draw boxes') 68 | } 69 | 70 | function drawBox( 71 | ctx: CanvasRenderingContext2D, 72 | x: number, 73 | y: number, 74 | w: number, 75 | h: number, 76 | ) { 77 | ctx.rect(x, y, w, h) 78 | 79 | ctx.font = '13px Arial' 80 | ctx.fillText('I am a commit message. Here is some text.', x + 5, y + h / 1.5) 81 | } 82 | 83 | export function canvasTest(root: HTMLElement) { 84 | console.time('render') 85 | 86 | render( 87 | TextCanvas.$({width: window.innerWidth, height: window.innerHeight}), 88 | document.body, 89 | ) 90 | 91 | console.timeEnd('render') 92 | } 93 | 94 | export function getCanvasContext( 95 | canvas: HTMLCanvasElement, 96 | width: number, 97 | height: number, 98 | ): CanvasRenderingContext2D | null { 99 | const scale = window.devicePixelRatio 100 | 101 | canvas.width = width * scale 102 | canvas.height = height * scale 103 | 104 | const ctx = canvas.getContext('2d') 105 | 106 | if (ctx !== null) ctx.scale(scale, scale) 107 | 108 | return ctx 109 | } 110 | -------------------------------------------------------------------------------- /src/lib/observables/computed/chained-computed.test.ts: -------------------------------------------------------------------------------- 1 | import {makeObservable} from '../$model' 2 | import {$AutoRun, F0} from '../responder' 3 | import {Computed} from './computed' 4 | 5 | describe('Computed', () => { 6 | class A { 7 | d: F0[] = [] 8 | runs = 0 9 | 10 | $o = 1 11 | 12 | declare __$c1: Computed 13 | get $c1(): number { 14 | return this.$o + 1 15 | } 16 | 17 | declare __$c2: Computed 18 | get $c2(): number { 19 | return this.$c1 + 1 20 | } 21 | 22 | declare __$c3: Computed 23 | get $c3(): number { 24 | return this.$c2 + 1 25 | } 26 | 27 | constructor() { 28 | makeObservable(this) 29 | } 30 | 31 | result = 0 32 | enableReactions() { 33 | this.d.push( 34 | $AutoRun(() => { 35 | this.runs++ 36 | this.result = this.$c3 37 | }) 38 | ) 39 | } 40 | 41 | disableReactions() { 42 | this.d.forEach(d => d()) 43 | this.d = [] 44 | } 45 | } 46 | 47 | test('Chain of computeds activates when called', () => { 48 | const a = new A() 49 | a.enableReactions() 50 | 51 | expect(a.__$c3.isMarkedActive()).toBe(true) 52 | 53 | expect(a.$c1).toBe(2) 54 | expect(a.$c2).toBe(3) 55 | expect(a.$c3).toBe(4) 56 | 57 | expect(a.runs).toBe(1) 58 | 59 | a.disableReactions() 60 | expect(a.__$c3.isMarkedActive()).toBe(true) 61 | 62 | a.$o = 2 63 | 64 | expect(a.__$c3.isMarkedActive()).toBe(false) 65 | expect(a.__$c2.isMarkedActive()).toBe(false) 66 | expect(a.__$c1.isMarkedActive()).toBe(false) 67 | 68 | // Call computeds outside reactive context. 69 | expect(a.$c3).toBe(5) 70 | expect(a.__$c3._ref.current).toBe(null) 71 | 72 | a.$o = 3 73 | 74 | expect(a.runs).toBe(1) 75 | a.enableReactions() 76 | expect(a.runs).toBe(2) 77 | 78 | expect(a.$c3).toBe(6) 79 | }) 80 | 81 | test('Chained computeds called outside reactive context', () => { 82 | const a = new A() 83 | a.enableReactions() 84 | 85 | expect(a.runs).toBe(1) 86 | 87 | a.disableReactions() 88 | 89 | a.$o = 2 90 | 91 | // Call computeds outside reactive context. 92 | a.$c1 93 | expect(a.__$c1.isMarkedActive()).toBe(false) 94 | a.$c2 95 | expect(a.__$c2.isMarkedActive()).toBe(false) 96 | a.$c3 97 | expect(a.__$c3.isMarkedActive()).toBe(false) 98 | 99 | a.$o = 3 100 | expect(a.__$c1.isMarkedActive()).toBe(false) 101 | expect(a.__$c2.isMarkedActive()).toBe(false) 102 | expect(a.__$c3.isMarkedActive()).toBe(false) 103 | 104 | expect(a.runs).toBe(1) 105 | a.enableReactions() 106 | 107 | expect(a.__$c1.isMarkedActive()).toBe(true) 108 | expect(a.__$c2.isMarkedActive()).toBe(true) 109 | expect(a.__$c3.isMarkedActive()).toBe(true) 110 | 111 | expect(a.runs).toBe(2) 112 | 113 | expect(a.$c3).toBe(6) 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /src/lib/component-types/host/set-attributes.test.ts: -------------------------------------------------------------------------------- 1 | import {setAttributesFromProps, updateAttributes} from './set-attributes' 2 | import {Rec} from '../pure-component' 3 | import {Button} from './dom-components' 4 | import {render} from '../../render' 5 | import {ElementNamespace} from '../../util/element' 6 | 7 | describe('setAttributesFromProps', () => { 8 | test('add class', () => { 9 | const parent = document.createElement('div') 10 | const div = document.createElement('div') 11 | 12 | parent.appendChild(div) 13 | 14 | const props: Partial = { 15 | className: 'simple', 16 | } 17 | 18 | setAttributesFromProps(div, ElementNamespace.html, props as Rec) 19 | 20 | expect(parent.innerHTML).toEqual('
') 21 | }) 22 | }) 23 | 24 | describe('updateAttributes', () => { 25 | test('update props', () => { 26 | const parent = document.createElement('div') 27 | const div = document.createElement('div') 28 | 29 | parent.appendChild(div) 30 | 31 | const props: Partial = { 32 | className: 'simple', 33 | } 34 | 35 | setAttributesFromProps(div, ElementNamespace.html, props as Rec) 36 | 37 | expect(parent.innerHTML).toEqual('
') 38 | 39 | const newProps: Partial = { 40 | id: 'simple', 41 | } 42 | 43 | updateAttributes(div, ElementNamespace.html, newProps as Rec, props as Rec) 44 | 45 | expect(parent.innerHTML).toEqual('
') 46 | }) 47 | }) 48 | 49 | describe('handling of undefined props', () => { 50 | test(`expect undefined attributes don't show on element on first set`, () => { 51 | const parent = document.createElement('div') 52 | const div = document.createElement('div') 53 | 54 | parent.appendChild(div) 55 | 56 | const props: Partial = { 57 | id: undefined, 58 | } 59 | 60 | setAttributesFromProps(div, ElementNamespace.html, props as Rec) 61 | 62 | expect(parent.innerHTML).toEqual('
') 63 | }) 64 | 65 | test(`expect undefined attributes don't show on element after previously defined`, () => { 66 | const parent = document.createElement('div') 67 | const div = document.createElement('div') 68 | 69 | parent.appendChild(div) 70 | 71 | const props: Partial = { 72 | id: 'simple', 73 | } 74 | 75 | setAttributesFromProps(div, ElementNamespace.html, props as Rec) 76 | 77 | expect(parent.innerHTML).toEqual('
') 78 | 79 | const newProps: Partial = { 80 | id: undefined, 81 | } 82 | 83 | updateAttributes(div, ElementNamespace.html, newProps as Rec, props as Rec) 84 | 85 | expect(parent.innerHTML).toEqual('
') 86 | }) 87 | }) 88 | 89 | describe('Applies expected attribute', () => { 90 | test('disabled', () => { 91 | render(Button({disabled: true}), document.body) 92 | const b = document.getElementsByTagName('button').item(0) 93 | expect(b?.disabled).toEqual(true) 94 | 95 | render(Button({disabled: false}), document.body) 96 | 97 | const b2 = document.getElementsByTagName('button').item(0) 98 | expect(b2?.disabled).toEqual(false) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /src/lib/observables/$model.ts: -------------------------------------------------------------------------------- 1 | import {Atom} from './atom' 2 | import {Computed} from './computed/computed' 3 | import {globalStack} from './global-stack' 4 | import {$AutoRun, $Reaction, F0} from './responder' 5 | 6 | export type Constructor = {new (...args: any[]): {}} 7 | 8 | export function makeObservable(object: Object) { 9 | makeObservableInner(object, object.constructor as Constructor) 10 | } 11 | 12 | export function makeObservableInner(object: Object, Con: Constructor) { 13 | for (const key in object) { 14 | if (key.startsWith('$')) { 15 | const valueName = `__${key}` 16 | const value: unknown = object[key as keyof object] 17 | 18 | if (!(value instanceof Function)) { 19 | Object.defineProperties(object, { 20 | [valueName]: { 21 | value: new Atom(value, valueName), 22 | }, 23 | [key]: { 24 | get() { 25 | return this[valueName].get(globalStack.getCurrentResponder()) 26 | }, 27 | set(value) { 28 | if (__FIEND_DEV__) console.debug(`${Con.name}.${key} <- `, value) 29 | 30 | this[valueName].set(value) 31 | }, 32 | }, 33 | }) 34 | } 35 | } 36 | } 37 | 38 | const descriptors: [string, TypedPropertyDescriptor][] = Object.entries( 39 | Object.getOwnPropertyDescriptors(Con.prototype) 40 | ) 41 | 42 | for (const [key, descriptor] of descriptors) { 43 | if (key.startsWith('$') && descriptor.get !== undefined) { 44 | const valueName = `__${key}` 45 | 46 | Object.defineProperties(object, { 47 | [valueName]: { 48 | value: new Computed(descriptor.get.bind(object), valueName), 49 | }, 50 | [key]: { 51 | get() { 52 | return this[valueName].get(globalStack.getCurrentResponder()) 53 | }, 54 | }, 55 | }) 56 | } 57 | } 58 | } 59 | 60 | // This lets you get the value of an observable without setting up a dependency/notifying. 61 | // This can help avoid a dependency infinite loop. 62 | // Only use if you are sure that the observable will be up-to-date. 63 | export function getObservableUntracked(object: T, key: K): T[K] { 64 | const untrackedKey = `__${String(key)}` as typeof key 65 | 66 | // @ts-ignore 67 | return object[untrackedKey].value 68 | } 69 | 70 | export class $Model { 71 | protected disposers: F0[] = [] 72 | 73 | constructor() { 74 | if (__DEV__) { 75 | setTimeout(() => { 76 | // @ts-ignore 77 | if (!Boolean(this.connected)) { 78 | console.error( 79 | `super.connect() wasn't called in the constructor of ${this.constructor.name}. This is require for $Model to work.` 80 | ) 81 | } 82 | }) 83 | } 84 | } 85 | 86 | connect() { 87 | makeObservableInner(this, this.constructor as Constructor) 88 | 89 | if (__DEV__) { 90 | // @ts-ignore 91 | this.connected = true 92 | } 93 | } 94 | 95 | $AutoRun(f: () => void) { 96 | this.disposers.push($AutoRun(f)) 97 | } 98 | 99 | $Reaction(calc: () => T, f: (result: T) => void) { 100 | this.disposers.push($Reaction(calc, f)) 101 | } 102 | 103 | disposeReactions(): void { 104 | for (const d of this.disposers) d() 105 | this.disposers = [] 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/lib/misc/compare.test.ts: -------------------------------------------------------------------------------- 1 | import {autorun, computed, IReactionDisposer, observable, runInAction} from 'mobx' 2 | import {$AutoRun} from '../observables/responder' 3 | import {makeObservable} from '../observables/$model' 4 | import {$Calc, $Val} from '../observables/value-style' 5 | 6 | xdescribe('compare mbox computeds with fiend-ui', () => { 7 | const loops = 1000 8 | 9 | test('time b', () => { 10 | class A { 11 | @observable 12 | a = 5 13 | 14 | @observable 15 | b = 5 16 | 17 | @observable 18 | c = 5 19 | 20 | @observable.ref 21 | d = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 22 | } 23 | 24 | class RunA { 25 | a = new A() 26 | 27 | disposer: IReactionDisposer 28 | 29 | constructor() { 30 | this.disposer = autorun(() => { 31 | const {a, b, c, d} = this.a 32 | 33 | this.a.d = [...d, a, b, c] 34 | }) 35 | } 36 | 37 | @computed 38 | get c(): number { 39 | return this.a.a + this.a.b + this.a.c 40 | } 41 | } 42 | 43 | console.time('mobx') 44 | let z = 0 45 | 46 | for (let i = 0; i < loops; i++) { 47 | const a = new RunA() 48 | 49 | runInAction(() => { 50 | a.a.a = 10 51 | }) 52 | runInAction(() => { 53 | a.a.a = 12 54 | }) 55 | runInAction(() => { 56 | a.a.a = 14 57 | }) 58 | 59 | z = a.c 60 | 61 | a.disposer() 62 | } 63 | expect(z).toEqual(24) 64 | 65 | console.timeEnd('mobx') 66 | }) 67 | 68 | test('fiend-ui', () => { 69 | class E { 70 | a = $Val(5) 71 | 72 | b = $Val(5) 73 | 74 | c = $Val(5) 75 | 76 | d = $Val([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 77 | } 78 | 79 | class Run { 80 | b = new E() 81 | 82 | constructor() { 83 | $AutoRun(() => { 84 | const {a, b, c, d} = this.b 85 | 86 | const array = [...d()] 87 | 88 | array.push(a(), b(), c()) 89 | 90 | d(array) 91 | }) 92 | } 93 | } 94 | 95 | console.time('fiend-ui') 96 | let z = 0 97 | for (let i = 0; i < loops; i++) { 98 | const r = new Run() 99 | 100 | const c = $Calc(() => { 101 | return r.b.a() + r.b.b() + r.b.c() 102 | }) 103 | 104 | r.b.a(10) 105 | r.b.a(12) 106 | r.b.a(14) 107 | z = c() 108 | // console.log('fiend-ui', z) 109 | } 110 | 111 | expect(z).toEqual(24) 112 | console.timeEnd('fiend-ui') 113 | }) 114 | 115 | test('fiend', () => { 116 | class E { 117 | $a = 5 118 | 119 | $b = 5 120 | 121 | $c = 5 122 | 123 | $d = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 124 | 125 | constructor() { 126 | makeObservable(this) 127 | } 128 | } 129 | 130 | class Run { 131 | b = new E() 132 | 133 | constructor() { 134 | $AutoRun(() => { 135 | const {$a, $b, $c, $d} = this.b 136 | 137 | this.b.$d = [...$d, $a, $b, $c] 138 | }) 139 | } 140 | } 141 | 142 | console.time('fiend') 143 | let z = 0 144 | for (let i = 0; i < loops; i++) { 145 | const r = new Run() 146 | 147 | const c = $Calc(() => { 148 | return r.b.$a + r.b.$b + r.b.$c 149 | }) 150 | 151 | r.b.$a = 10 152 | r.b.$a = 12 153 | r.b.$a = 14 154 | z = c() 155 | // console.log('fiend-ui', z) 156 | } 157 | 158 | expect(z).toEqual(24) 159 | console.timeEnd('fiend') 160 | }) 161 | }) 162 | -------------------------------------------------------------------------------- /src/lib/util/order.ts: -------------------------------------------------------------------------------- 1 | import {DomComponent} from '../component-types/host/dom-component' 2 | import {RootComponent} from '../component-types/root-component' 3 | import {ElementComponent} from '../component-types/base-component' 4 | import {RunStack} from '../observables/run-stack' 5 | 6 | // export function checkOrder(inserted: ElementComponent[]) { 7 | // if (!__DEV__) return 8 | // 9 | // let prev: ElementComponent | null = null 10 | // 11 | // for (const c of inserted) { 12 | // console.log(c.key, prev?.key) 13 | // if (prev !== null) { 14 | // if (prev.element !== c.element.previousElementSibling) { 15 | // console.log('elements out of order') 16 | // // debugger 17 | // } 18 | // if (prev.order > c.order) { 19 | // console.log('keys out of order') 20 | // } 21 | // } 22 | // prev = c 23 | // } 24 | // } 25 | 26 | export function applyInserts(parent: RootComponent | DomComponent): void { 27 | const {inserted, siblings, element} = parent 28 | 29 | const len = inserted.length 30 | 31 | let next: ElementComponent | null = null 32 | 33 | for (let i = len - 1; i >= 0; i--) { 34 | const current = inserted[i] 35 | 36 | if (next === null) { 37 | if (!siblings.has(current.element)) { 38 | element.insertBefore(current.element, null) 39 | siblings.set(current.element, null) 40 | } 41 | } else if (siblings.has(next.element)) { 42 | const prevElement = siblings.get(next.element) 43 | 44 | if (prevElement !== current.element) { 45 | element.insertBefore(current.element, next.element) 46 | siblings.set(next.element, current.element) 47 | if (!siblings.has(current.element)) siblings.set(current.element, null) 48 | } 49 | } 50 | 51 | next = current 52 | } 53 | } 54 | 55 | export class Order { 56 | static key(parentOrder: string, index: number): string { 57 | return parentOrder + String.fromCharCode(index + 48) 58 | } 59 | 60 | static insert(parent: RootComponent | DomComponent, child: ElementComponent): void { 61 | const {inserted} = parent 62 | const {order, key} = child 63 | 64 | const len = inserted.length 65 | 66 | for (let i = len - 1; i >= 0; i--) { 67 | const current = inserted[i] 68 | const next: ElementComponent | undefined = inserted[i + 1] 69 | 70 | /* 71 | If order is the same we expect the keys to be different. This 72 | is expected for a virtual list. 73 | */ 74 | if (order >= current.order) { 75 | if (key !== current.key) { 76 | if (next != null) { 77 | inserted.splice(i + 1, 0, child) 78 | RunStack.insertsStack.add(parent) 79 | } else { 80 | inserted.push(child) 81 | RunStack.insertsStack.add(parent) 82 | } 83 | } 84 | 85 | return 86 | } 87 | } 88 | 89 | inserted.unshift(child) 90 | RunStack.insertsStack.add(parent) 91 | } 92 | 93 | static move(parent: RootComponent | DomComponent, child: ElementComponent) { 94 | const {inserted} = parent 95 | const {key} = child 96 | 97 | const i = inserted.findIndex(ins => ins.key === key) 98 | 99 | if (i >= 0) { 100 | inserted.splice(i, 1) 101 | } 102 | 103 | this.insert(parent, child) 104 | } 105 | 106 | static remove(parent: RootComponent | DomComponent, child: ElementComponent): void { 107 | const {inserted, siblings} = parent 108 | const {key} = child 109 | 110 | const i = inserted.findIndex(i => i.key === key) 111 | 112 | if (i >= 0) { 113 | const [child] = inserted.splice(i, 1) 114 | 115 | siblings.delete(child.element) 116 | RunStack.removeStack.add(child.element) 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/lib/observables/$component.test.ts: -------------------------------------------------------------------------------- 1 | import {$Component} from './$component' 2 | import {Div, FiendNode, makeObservable, PureComponent} from '../..' 3 | import {mkRoot} from '../../dom-tests/test-helpers' 4 | 5 | describe('$Component', () => { 6 | test('order 1', () => { 7 | class Store { 8 | $num = 5 9 | 10 | constructor() { 11 | makeObservable(this) 12 | } 13 | } 14 | 15 | interface Props { 16 | store: Store 17 | depth: number 18 | } 19 | 20 | class A extends $Component { 21 | render(): FiendNode | null { 22 | const {store, depth} = this.props 23 | 24 | if (depth <= 0) return null 25 | 26 | return Div({ 27 | children: [`${store.$num} - ${depth}`, A.$({store, depth: depth - 1})], 28 | }) 29 | } 30 | } 31 | 32 | const root = mkRoot() 33 | const store = new Store() 34 | 35 | root.render(A.$({store, depth: 3})) 36 | 37 | expect(root.element.innerHTML).toEqual( 38 | '
5 - 3
5 - 2
5 - 1
' 39 | ) 40 | 41 | store.$num = 6 42 | 43 | expect(root.element.innerHTML).toEqual( 44 | '
6 - 3
6 - 2
6 - 1
' 45 | ) 46 | }) 47 | 48 | test('order without keys', () => { 49 | const root = mkRoot() 50 | 51 | const a = Div({children: [Div('a')]}) 52 | 53 | root.render(a) 54 | 55 | expect(root.element.innerHTML).toEqual('
a
') 56 | 57 | const b = Div({children: [Div('b'), Div('a')]}) 58 | 59 | root.render(b) 60 | 61 | expect(root.element.innerHTML).toEqual('
b
a
') 62 | }) 63 | 64 | test('order, no keys, custom component', () => { 65 | const root = mkRoot() 66 | 67 | class DivC extends PureComponent { 68 | render(): FiendNode { 69 | return Div({children: this.props.children}) 70 | } 71 | } 72 | 73 | const a = DivC.$({children: [DivC.$({children: ['a']})]}) 74 | 75 | root.render(a) 76 | 77 | expect(root.element.innerHTML).toEqual('
a
') 78 | 79 | const b = DivC.$({children: [DivC.$({children: ['b']}), DivC.$({children: ['a']})]}) 80 | 81 | root.render(b) 82 | 83 | expect(root.element.innerHTML).toEqual('
b
a
') 84 | }) 85 | 86 | test('order 2, without keys', () => { 87 | const root = mkRoot() 88 | 89 | const a = Div({children: [Div('a'), Div('b'), Div('c')]}) 90 | 91 | root.render(a) 92 | 93 | expect(root.element.innerHTML).toEqual( 94 | '
a
b
c
' 95 | ) 96 | 97 | const b = Div({children: [Div('d'), Div('a'), Div('b'), Div('c')]}) 98 | 99 | root.render(b) 100 | 101 | expect(root.element.innerHTML).toEqual( 102 | '
d
a
b
c
' 103 | ) 104 | }) 105 | 106 | test('order 3, with keys', () => { 107 | const root = mkRoot() 108 | 109 | const a = Div({ 110 | children: [ 111 | Div({children: ['a'], key: 'a'}), 112 | Div({children: ['b'], key: 'b'}), 113 | Div({children: ['c'], key: 'c'}), 114 | ], 115 | }) 116 | 117 | root.render(a) 118 | 119 | expect(root.element.innerHTML).toEqual( 120 | '
a
b
c
' 121 | ) 122 | 123 | const b = Div({ 124 | children: [ 125 | Div({children: ['d'], key: 'd'}), 126 | Div({children: ['a'], key: 'a'}), 127 | Div({children: ['b'], key: 'b'}), 128 | Div({children: ['c'], key: 'c'}), 129 | ], 130 | }) 131 | 132 | root.render(b) 133 | 134 | expect(root.element.innerHTML).toEqual( 135 | '
d
a
b
c
' 136 | ) 137 | }) 138 | }) 139 | -------------------------------------------------------------------------------- /src/lib/component-types/host/set-attributes.ts: -------------------------------------------------------------------------------- 1 | import {RefObject} from '../../util/ref' 2 | import {ElementNamespace, StandardProps} from '../../util/element' 3 | 4 | // This should only be called the first time or if previous props were null. 5 | export function setAttributesFromProps

( 6 | element: Element, 7 | namespace: ElementNamespace, 8 | props: P, 9 | ): void { 10 | const attributes = Object.keys(props) 11 | 12 | for (const attr of attributes) { 13 | // @ts-ignore 14 | const value = props[attr] 15 | 16 | if (value !== undefined) { 17 | // @ts-ignore 18 | setAttribute(element, namespace, attr, props[attr]) 19 | } 20 | } 21 | } 22 | 23 | export function updateAttributes( 24 | element: Element, 25 | namespace: ElementNamespace, 26 | newProps: object, 27 | oldProps: object | null, 28 | ): void { 29 | if (oldProps === null) { 30 | setAttributesFromProps(element, namespace, newProps) 31 | } else { 32 | updateAttrInner(element, namespace, newProps, oldProps) 33 | } 34 | } 35 | 36 | // TODO: Could we remove a loop by using Array.from? 37 | function updateAttrInner( 38 | element: Element, 39 | namespace: ElementNamespace, 40 | newProps: object, 41 | oldProps: object, 42 | ): void { 43 | const newKeys = Object.keys(newProps) 44 | const oldKeys = Object.keys(oldProps) 45 | 46 | for (const key of newKeys) { 47 | // @ts-ignore 48 | const oldValue = oldProps[key] 49 | // @ts-ignore 50 | const newValue = newProps[key] 51 | 52 | if (newValue === undefined) continue 53 | 54 | if (oldValue === undefined) { 55 | setAttribute(element, namespace, key, newValue) 56 | } // 57 | else if (oldValue !== newValue) { 58 | if (key.startsWith('on')) deleteAttribute(element, key, oldValue) 59 | 60 | setAttribute(element, namespace, key, newValue) 61 | } 62 | } 63 | 64 | for (const key of oldKeys) { 65 | // @ts-ignore 66 | const oldValue = oldProps[key] 67 | // @ts-ignore 68 | const newValue = newProps[key] 69 | 70 | if (newValue === undefined && oldValue !== undefined) { 71 | deleteAttribute(element, key, oldValue) 72 | } 73 | } 74 | } 75 | 76 | // TODO: Improve types. 77 | function setAttribute( 78 | element: Element, 79 | namespace: ElementNamespace, 80 | attr: string, 81 | value: any, 82 | ): void { 83 | switch (attr) { 84 | case 'key': 85 | case 'children': 86 | break 87 | case 'className': 88 | element.setAttribute('class', value) 89 | break 90 | case 'value': 91 | case 'style': 92 | ;(element as any)[attr] = value 93 | break 94 | case 'ref': 95 | ;(value as RefObject).current = element 96 | break 97 | default: 98 | if (namespace === ElementNamespace.svg && setSvgAttribute(element, attr, value)) 99 | break 100 | 101 | if (attr.startsWith('on')) { 102 | element.addEventListener(attr.slice(2), value) 103 | } else if (typeof value === 'boolean') { 104 | if (value) element.setAttribute(attr, '') 105 | else element.removeAttribute(attr) 106 | } else { 107 | element.setAttribute(attr, value) 108 | } 109 | break 110 | } 111 | } 112 | 113 | function setSvgAttribute(element: Element, attr: string, value: any) { 114 | switch (attr) { 115 | case 'strokeWidth': 116 | element.setAttribute('stroke-width', value) 117 | return true 118 | case 'strokeLinejoin': 119 | element.setAttribute('stroke-linejoin', value) 120 | return true 121 | } 122 | 123 | return false 124 | } 125 | 126 | function deleteAttribute(element: Element, attr: string, oldValue: unknown): void { 127 | if (attr.startsWith('on')) { 128 | element.removeEventListener(attr.slice(2), oldValue as any) 129 | } else if (attr === 'className') { 130 | element.removeAttribute('class') 131 | } else if (attr === 'ref') { 132 | ;(oldValue as RefObject).current = null 133 | } else { 134 | element.removeAttribute(attr) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff 7 | .idea/**/workspace.xml 8 | .idea/**/tasks.xml 9 | .idea/**/usage.statistics.xml 10 | .idea/**/dictionaries 11 | .idea/**/shelf 12 | 13 | # Generated files 14 | .idea/**/contentModel.xml 15 | 16 | # Sensitive or high-churn files 17 | .idea/**/dataSources/ 18 | .idea/**/dataSources.ids 19 | .idea/**/dataSources.local.xml 20 | .idea/**/sqlDataSources.xml 21 | .idea/**/dynamic.xml 22 | .idea/**/uiDesigner.xml 23 | .idea/**/dbnavigator.xml 24 | 25 | # Gradle 26 | .idea/**/gradle.xml 27 | .idea/**/libraries 28 | 29 | # Gradle and Maven with auto-import 30 | # When using Gradle or Maven with auto-import, you should exclude module files, 31 | # since they will be recreated, and may cause churn. Uncomment if using 32 | # auto-import. 33 | # .idea/artifacts 34 | # .idea/compiler.xml 35 | # .idea/modules.xml 36 | # .idea/*.iml 37 | # .idea/modules 38 | # *.iml 39 | # *.ipr 40 | 41 | # CMake 42 | cmake-build-*/ 43 | 44 | # Mongo Explorer plugin 45 | .idea/**/mongoSettings.xml 46 | 47 | # File-based project format 48 | *.iws 49 | 50 | # IntelliJ 51 | out/ 52 | 53 | # mpeltonen/sbt-idea plugin 54 | .idea_modules/ 55 | 56 | # JIRA plugin 57 | atlassian-ide-plugin.xml 58 | 59 | # Cursive Clojure plugin 60 | .idea/replstate.xml 61 | 62 | # Crashlytics plugin (for Android Studio and IntelliJ) 63 | com_crashlytics_export_strings.xml 64 | crashlytics.properties 65 | crashlytics-build.properties 66 | fabric.properties 67 | 68 | # Editor-based Rest Client 69 | .idea/httpRequests 70 | 71 | # Android studio 3.1+ serialized cache file 72 | .idea/caches/build_file_checksums.ser 73 | 74 | ### Node template 75 | # Logs 76 | logs 77 | *.log 78 | npm-debug.log* 79 | yarn-debug.log* 80 | yarn-error.log* 81 | lerna-debug.log* 82 | 83 | # Diagnostic reports (https://nodejs.org/api/report.html) 84 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 85 | 86 | # Runtime data 87 | pids 88 | *.pid 89 | *.seed 90 | *.pid.lock 91 | 92 | # Directory for instrumented libs generated by jscoverage/JSCover 93 | lib-cov 94 | 95 | # Coverage directory used by tools like istanbul 96 | coverage 97 | *.lcov 98 | 99 | # nyc test coverage 100 | .nyc_output 101 | 102 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 103 | .grunt 104 | 105 | # Bower dependency directory (https://bower.io/) 106 | bower_components 107 | 108 | # node-waf configuration 109 | .lock-wscript 110 | 111 | # Compiled binary addons (https://nodejs.org/api/addons.html) 112 | build/Release 113 | 114 | # Dependency directories 115 | node_modules/ 116 | jspm_packages/ 117 | 118 | # TypeScript v1 declaration files 119 | typings/ 120 | 121 | # TypeScript cache 122 | *.tsbuildinfo 123 | 124 | # Optional npm cache directory 125 | .npm 126 | 127 | # Optional eslint cache 128 | .eslintcache 129 | 130 | # Microbundle cache 131 | .rpt2_cache/ 132 | .rts2_cache_cjs/ 133 | .rts2_cache_es/ 134 | .rts2_cache_umd/ 135 | 136 | # Optional REPL history 137 | .node_repl_history 138 | 139 | # Output of 'npm pack' 140 | *.tgz 141 | 142 | # Yarn Integrity file 143 | .yarn-integrity 144 | 145 | # dotenv environment variables file 146 | .env 147 | .env.test 148 | 149 | # parcel-bundler cache (https://parceljs.org/) 150 | .cache 151 | 152 | # Next.js build output 153 | .next 154 | 155 | # Nuxt.js build / generate output 156 | .nuxt 157 | dist 158 | 159 | # Gatsby files 160 | .cache/ 161 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 162 | # https://nextjs.org/blog/next-9-1#public-directory-support 163 | # public 164 | 165 | # vuepress build output 166 | .vuepress/dist 167 | 168 | # Serverless directories 169 | .serverless/ 170 | 171 | # FuseBox cache 172 | .fusebox/ 173 | 174 | # DynamoDB Local files 175 | .dynamodb/ 176 | 177 | # TernJS port file 178 | .tern-port 179 | 180 | /output-code/ 181 | -------------------------------------------------------------------------------- /src/lib/component-types/host/dom-component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AnyComponent, 3 | ComponentType, 4 | ElementComponent, 5 | ParentComponent, 6 | } from '../base-component' 7 | import {Render} from '../../render' 8 | import {setAttributesFromProps, updateAttributes} from './set-attributes' 9 | import {ElementNameMap} from './dom-component-types' 10 | import {Order} from '../../util/order' 11 | import {TextComponent} from '../text-component' 12 | import {RootComponent} from '../root-component' 13 | import {FiendNode} from '../../..' 14 | import { 15 | ElementNamespace, 16 | HostElement, 17 | StandardProps, 18 | SvgElement, 19 | } from '../../util/element' 20 | 21 | export class DomComponent

{ 22 | _type = ComponentType.host as const 23 | element: ElementNameMap[this['tag']] 24 | order: string 25 | key: string 26 | 27 | subComponents = new Map() 28 | inserted: ElementComponent[] = [] 29 | 30 | // key is an element, value is the previous element 31 | siblings = new WeakMap() 32 | 33 | constructor( 34 | public tag: keyof ElementNameMap, 35 | public namespace: ElementNamespace, 36 | public props: P, 37 | public domParent: DomComponent | RootComponent, 38 | directParent: ParentComponent, 39 | public index: number, 40 | ) { 41 | this.order = Order.key(directParent.order, index) 42 | this.key = this.props.key ?? directParent.key + index 43 | 44 | if (namespace === ElementNamespace.svg) { 45 | this.element = document.createElementNS( 46 | 'http://www.w3.org/2000/svg', 47 | tag, 48 | ) as ElementNameMap[this['tag']] 49 | } else { 50 | this.element = document.createElement(tag) as ElementNameMap[this['tag']] 51 | } 52 | 53 | setAttributesFromProps(this.element, namespace, props) 54 | 55 | this.renderSubtrees(props.children ?? []) 56 | 57 | domParent.insertChild(this) 58 | } 59 | 60 | renderSubtrees(children: FiendNode[]) { 61 | this.subComponents = Render.subComponents(this, this, children, this.subComponents) 62 | } 63 | 64 | // What if our sub component has lots of elements to insert? 65 | insertChild(child: DomComponent | TextComponent) { 66 | Order.insert(this, child) 67 | } 68 | 69 | moveChild(child: DomComponent | TextComponent) { 70 | Order.move(this, child) 71 | } 72 | 73 | removeChild(child: DomComponent | TextComponent): void { 74 | Order.remove(this, child) 75 | } 76 | 77 | remove(): void { 78 | this.domParent.removeChild(this) 79 | 80 | if (this.subComponents.size > 0) { 81 | // This is required so that observer components don't keep updating. 82 | for (const c of this.subComponents.values()) c.remove() 83 | this.subComponents.clear() 84 | } 85 | 86 | if (this.inserted.length > 0) this.inserted = [] 87 | } 88 | } 89 | 90 | // TODO: prevTree.children? parent.children? Seems there might be a bug here. 91 | export function renderDom( 92 | tree: HostElement | SvgElement, 93 | prev: AnyComponent | null, 94 | domParent: RootComponent | DomComponent, 95 | directParent: ParentComponent, 96 | index: number, 97 | ): DomComponent { 98 | const {_type, namespace, props} = tree 99 | 100 | if (prev === null) { 101 | return new DomComponent(_type, namespace, props, domParent, directParent, index) 102 | } 103 | 104 | if (prev._type === ComponentType.host && prev.tag === _type) { 105 | const prevOrder = prev.order 106 | const newOrder = Order.key(directParent.order, index) 107 | 108 | if (prevOrder !== newOrder) { 109 | prev.index = index 110 | prev.order = newOrder 111 | 112 | domParent.moveChild(prev) 113 | } 114 | 115 | updateAttributes(prev.element, namespace, props, prev.props) 116 | prev.props = props 117 | prev.renderSubtrees(props.children ?? []) 118 | 119 | return prev 120 | } else { 121 | // Type has changed. Remove it. 122 | prev.remove() 123 | 124 | return new DomComponent(_type, namespace, props, domParent, directParent, index) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/lib/component-types/component.test.ts: -------------------------------------------------------------------------------- 1 | import {PureComponent} from './pure-component' 2 | import {Div} from './host/dom-components' 3 | import {$Component, makeObservable} from '../..' 4 | import {mkRoot} from '../../dom-tests/test-helpers' 5 | // import {$Model} from '../..' 6 | 7 | describe('component', () => { 8 | test('null in render should remove previous elements', () => { 9 | class A extends PureComponent<{ok: boolean}> { 10 | render() { 11 | const {ok} = this.props 12 | 13 | if (!ok) return null 14 | return Div('OK') 15 | } 16 | } 17 | 18 | const root = mkRoot() 19 | 20 | root.render(A.$({ok: true})) 21 | 22 | expect(root.element.innerHTML).toEqual(`

OK
`) 23 | 24 | root.render(A.$({ok: false})) 25 | 26 | expect(root.element.innerHTML).toEqual(``) 27 | }) 28 | }) 29 | 30 | describe('switch between custom and host component', () => { 31 | class M { 32 | constructor() { 33 | makeObservable(this) 34 | } 35 | $custom = false 36 | } 37 | 38 | class DivC extends PureComponent { 39 | render() { 40 | return Div('custom') 41 | } 42 | } 43 | 44 | test('switch to custom from host', () => { 45 | class A extends $Component<{model: M}> { 46 | render() { 47 | const {model} = this.props 48 | 49 | if (model.$custom) { 50 | return DivC.$({}) 51 | } 52 | return Div('host') 53 | } 54 | } 55 | 56 | const root = mkRoot() 57 | const model = new M() 58 | 59 | root.render(A.$({model})) 60 | expect(root.element.innerHTML).toEqual('
host
') 61 | 62 | model.$custom = true 63 | expect(root.element.innerHTML).toEqual('
custom
') 64 | 65 | model.$custom = false 66 | expect(root.element.innerHTML).toEqual('
host
') 67 | }) 68 | 69 | test('switch one inside array', () => { 70 | class A extends $Component<{model: M}> { 71 | render() { 72 | const {model} = this.props 73 | 74 | return [Div('a'), model.$custom ? DivC.$({}) : Div('host')] 75 | } 76 | } 77 | 78 | const root = mkRoot() 79 | const model = new M() 80 | 81 | root.render(A.$({model})) 82 | expect(root.element.innerHTML).toEqual('
a
host
') 83 | 84 | model.$custom = true 85 | expect(root.element.innerHTML).toEqual('
a
custom
') 86 | 87 | model.$custom = false 88 | expect(root.element.innerHTML).toEqual('
a
host
') 89 | }) 90 | 91 | test('switch one to null', () => { 92 | class A extends $Component<{model: M}> { 93 | render() { 94 | const {model} = this.props 95 | 96 | return [model.$custom ? DivC.$({}) : null, Div('a')] 97 | } 98 | } 99 | 100 | const root = mkRoot() 101 | const model = new M() 102 | 103 | root.render(A.$({model})) 104 | expect(root.element.innerHTML).toEqual('
a
') 105 | 106 | model.$custom = true 107 | expect(root.element.innerHTML).toEqual('
custom
a
') 108 | 109 | model.$custom = false 110 | expect(root.element.innerHTML).toEqual('
a
') 111 | }) 112 | 113 | // test('scrolling', () => { 114 | // class Scroller extends PureComponent<{n: number}> { 115 | // render() { 116 | // const {n} = this.props 117 | // 118 | // return [ 119 | // div({children: [n], key: `${n}`}), 120 | // div({children: [n + 1], key: `${n + 1}`}), 121 | // div({children: [n + 2], key: `${n + 2}`}), 122 | // ] 123 | // } 124 | // } 125 | // 126 | // const root = mkRoot() 127 | // 128 | // root.render(Scroller.$({n: 0})) 129 | // expect(root.element.innerHTML).toEqual('
0
1
2
') 130 | // 131 | // root.render(Scroller.$({n: 1})) 132 | // expect(root.element.innerHTML).toEqual('
1
2
3
') 133 | // 134 | // root.render(Scroller.$({n: 2})) 135 | // expect(root.element.innerHTML).toEqual('
2
3
4
') 136 | // }) 137 | }) 138 | -------------------------------------------------------------------------------- /src/lib/observables/action.ts: -------------------------------------------------------------------------------- 1 | import {globalStack} from './global-stack' 2 | import {Responder, UnorderedResponder} from './responder' 3 | import {Notifier} from './notifier' 4 | import {$Component} from './$component' 5 | import {RunStack} from './run-stack' 6 | import {RefObject} from '../util/ref' 7 | 8 | type FunctionWithoutPromise = T extends () => Promise ? never : T 9 | 10 | // Passed function needs to be synchronous otherwise it won't really be run inside action. 11 | export function $RunInAction(f: FunctionWithoutPromise<() => void | undefined>): void { 12 | globalStack.startAction() 13 | f() 14 | globalStack.endAction() 15 | } 16 | 17 | type ForbidPromise = T extends Promise ? never : T 18 | 19 | /** 20 | * Wraps passed in function in an action. Keeps the same type signature. 21 | * 22 | * E.g 23 | * const square = action((n: number) => n * n) 24 | * 25 | * @param f 26 | */ 27 | export const $Action = (f: (...args: T) => ForbidPromise) => { 28 | return (...args: T): ForbidPromise => { 29 | globalStack.startAction() 30 | const result = f(...args) 31 | globalStack.endAction() 32 | 33 | return result 34 | } 35 | } 36 | 37 | /** @deprecated */ 38 | export const $AsyncAction = (f: (...args: T) => Promise) => { 39 | return async (...args: T): Promise => { 40 | globalStack.startAction() 41 | const result = await f(...args) 42 | globalStack.endAction() 43 | 44 | return result 45 | } 46 | } 47 | 48 | /* 49 | An Action lets us batch our notifiers. 50 | 51 | */ 52 | export class ActionState { 53 | computeds = new Set>() 54 | reactions = new Set>() 55 | components = new Map>() 56 | 57 | constructor(public runningResponder: Responder | null) {} 58 | 59 | add(notifier: Notifier) { 60 | for (const r of notifier.computeds) { 61 | if (r.current !== this.runningResponder) this.computeds.add(r) 62 | } 63 | for (const r of notifier.reactions) { 64 | if (r.current !== this.runningResponder) this.reactions.add(r) 65 | } 66 | // for (const [order, r] of notifier.components) { 67 | // if (r.current !== this.runningResponder) this.components.set(order, r) 68 | // } 69 | for (const c of notifier.components.values()) { 70 | if (c.current && c.current !== this.runningResponder) { 71 | this.components.set(c.current?.order, c) 72 | } 73 | } 74 | 75 | if (notifier.computeds.size > 0) { 76 | notifier.computeds.clear() 77 | } 78 | if (notifier.reactions.size > 0) { 79 | notifier.reactions.clear() 80 | } 81 | if (notifier.components.size > 0) { 82 | notifier.components.clear() 83 | } 84 | } 85 | 86 | // This whole object gets deleted after running, so I don't think cleanup is required. 87 | run() { 88 | RunStack.runResponders(this.computeds, this.reactions, this.components) 89 | } 90 | } 91 | 92 | /* 93 | 94 | setting an observable: 95 | -> computeds 96 | -> autorun/reaction 97 | -> components 98 | 99 | */ 100 | 101 | /* 102 | LayoutSt.$scrollTop <- 1000 103 | 104 | RefVirtualPositions.$firstVisible <- 0 105 | RefVirtualPositions.$lastVisible <- 10 106 | RefVirtualPositions.$current <- (2000)[…] 107 | 108 | CommitVirtualPositions.$current <- (2000)[…] 109 | CommitVirtualPositions.$firstVisible <- 0 110 | CommitVirtualPositions.$lastVisible <- 66 111 | 112 | RefVirtualPositions.$firstVisible <- 8 113 | RefVirtualPositions.$lastVisible <- 10 114 | RefVirtualPositions.$current <- (2000)[…] 115 | 116 | $component.ts?49e1:20 run CommitCardList 117 | commit-card-list.ts?2833:84 CommitCardList render 118 | commit-card-list.ts?2833:34 CommitCardListInner render 1000 119 | $component.ts?49e1:20 run CommitCardBackground 120 | $component.ts?49e1:20 run CommitCardListInner 121 | commit-card-list.ts?2833:34 CommitCardListInner render 1000 122 | 123 | Explanation: 124 | scrollTop changes, so ref positions are recalculated. 125 | scrollTop also causes commit positions to change, which in turn causes a 126 | rerun of ref positions. 127 | 128 | 129 | */ 130 | -------------------------------------------------------------------------------- /src/lib/render.ts: -------------------------------------------------------------------------------- 1 | import {AnyComponent, ParentComponent} from './component-types/base-component' 2 | import {DomComponent, renderDom} from './component-types/host/dom-component' 3 | import {renderTextComponent} from './component-types/text-component' 4 | import {PureComponent, renderCustom} from './component-types/pure-component' 5 | import {RootComponent} from './component-types/root-component' 6 | import {ElementType, FiendElement, FiendNode} from './util/element' 7 | 8 | class RenderManager { 9 | rootNode: RootComponent | null = null 10 | target: HTMLElement | null = null 11 | 12 | render(tree: FiendElement, target: HTMLElement) { 13 | if (this.target !== target) { 14 | this.clear() 15 | this.rootNode = new RootComponent(target) 16 | } else if (this.rootNode === null) { 17 | this.rootNode = new RootComponent(target) 18 | } 19 | 20 | this.rootNode.render(tree) 21 | } 22 | 23 | clear() { 24 | this.rootNode?.remove() 25 | this.rootNode = null 26 | } 27 | } 28 | 29 | const renderManager = new RenderManager() 30 | 31 | export class Render { 32 | static component( 33 | tree: FiendElement, 34 | prevTree: AnyComponent | null, 35 | parentHost: DomComponent | RootComponent, 36 | directParent: ParentComponent, 37 | index: number, 38 | ): DomComponent | PureComponent { 39 | if (tree.elementType === ElementType.dom) { 40 | return renderDom(tree, prevTree, parentHost, directParent, index) 41 | } else { 42 | return renderCustom(tree, prevTree, parentHost, directParent, index) 43 | } 44 | } 45 | 46 | static subComponents( 47 | parentHost: DomComponent | RootComponent, 48 | directParent: ParentComponent, 49 | children: FiendNode[], 50 | prevComponents: Map, 51 | ): Map { 52 | const newComponents = new Map() 53 | 54 | const len = children.length - 1 55 | 56 | if (__DEV__) { 57 | checkChildrenKeys(children) 58 | } 59 | 60 | for (let i = len; i >= 0; i--) { 61 | const child = children[i] 62 | 63 | if (child !== null) { 64 | this.subComponent( 65 | parentHost, 66 | directParent, 67 | child, 68 | prevComponents, 69 | newComponents, 70 | i, 71 | ) 72 | } 73 | } 74 | 75 | for (const c of prevComponents.values()) c.remove() 76 | 77 | return newComponents 78 | } 79 | 80 | private static subComponent( 81 | parentHost: DomComponent | RootComponent, 82 | directParent: ParentComponent, 83 | subtree: FiendElement | string, 84 | prevChildren: Map, 85 | newChildren: Map, 86 | index: number, 87 | ): AnyComponent { 88 | if (typeof subtree === 'string') { 89 | const key = index.toString() 90 | 91 | const s = renderTextComponent( 92 | subtree, 93 | prevChildren.get(key) ?? null, 94 | parentHost, 95 | directParent, 96 | index, 97 | ) 98 | prevChildren.delete(key) 99 | newChildren.set(key, s) 100 | return s 101 | } 102 | 103 | const key: string = subtree.props.key ?? index.toString() 104 | const s = this.component( 105 | subtree, 106 | prevChildren.get(key) ?? null, 107 | parentHost, 108 | directParent, 109 | index, 110 | ) 111 | prevChildren.delete(key) 112 | newChildren.set(key, s) 113 | return s 114 | } 115 | } 116 | 117 | export function render(tree: FiendElement | null, target: HTMLElement): void { 118 | if (tree === null) renderManager.clear() 119 | else renderManager.render(tree, target) 120 | } 121 | 122 | function checkChildrenKeys(children: FiendNode[]) { 123 | let numKeys = 0 124 | const set = new Set() 125 | 126 | for (const child of children) { 127 | if (child !== null && typeof child !== 'string') { 128 | if (typeof child.props.key === 'string') { 129 | numKeys++ 130 | set.add(child.props.key) 131 | } 132 | } 133 | } 134 | 135 | if (numKeys !== set.size) { 136 | console.error(`Subtrees contain duplicate keys: `, children) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/lib/observables/$model.test.ts: -------------------------------------------------------------------------------- 1 | import {makeObservable} from './$model' 2 | import {$AutoRun} from './responder' 3 | import {observable} from 'mobx' 4 | import {$Val} from './value-style' 5 | 6 | class Model { 7 | $a = 5 8 | $b = 6 9 | 10 | constructor() { 11 | makeObservable(this) 12 | } 13 | } 14 | 15 | class Val { 16 | a = $Val(5) 17 | b = $Val(6) 18 | } 19 | 20 | class Mobx { 21 | @observable 22 | a = 5 23 | 24 | @observable 25 | b = 6 26 | } 27 | 28 | xdescribe('new fiend-ui class construction speed', () => { 29 | const loops = 10_000 30 | 31 | timeConstructor(Val, 'plain', loops) 32 | timeConstructor(Model, 'decorator', loops) 33 | timeConstructor(Mobx, 'mobx', loops) 34 | timeConstructor2('static', loops) 35 | 36 | test('construct once time', () => { 37 | console.time('cold construction') 38 | new Model() 39 | console.timeEnd('cold construction') 40 | }) 41 | }) 42 | 43 | function timeConstructor( 44 | C: T, 45 | name: string, 46 | loops: number 47 | ) { 48 | test(name, () => { 49 | console.time(name) 50 | let c 51 | for (let i = 0; i < loops; i++) { 52 | c = new C() 53 | } 54 | console.timeEnd(name) 55 | }) 56 | } 57 | 58 | function timeConstructor2(name: string, loops: number) { 59 | test(name, () => { 60 | console.time(name) 61 | const t = Date.now() 62 | 63 | let c 64 | for (let i = 0; i < loops; i++) { 65 | c = new Model() 66 | } 67 | 68 | const duration = Date.now() - t 69 | console.log(`${name} took ${duration}ms, ${duration / loops}ms each`) 70 | console.timeEnd(name) 71 | }) 72 | } 73 | 74 | xdescribe('access and set observable speed', () => { 75 | const loops = 100_000 76 | 77 | test('plain', () => { 78 | console.time('plain') 79 | const c = new Val() 80 | 81 | for (let i = 0; i < loops; i++) { 82 | c.a(c.a() + 1) 83 | c.b(c.a()) 84 | } 85 | console.timeEnd('plain') 86 | }) 87 | 88 | test('mobx', () => { 89 | console.time('mobx') 90 | const c = new Mobx() 91 | for (let i = 0; i < loops; i++) { 92 | c.a = c.a + 1 93 | c.b = c.a 94 | } 95 | console.timeEnd('mobx') 96 | }) 97 | 98 | test('decorator', () => { 99 | console.time('decorator') 100 | const c = new Model() 101 | for (let i = 0; i < loops; i++) { 102 | c.$a = c.$a + 1 103 | c.$b = c.$a 104 | } 105 | console.timeEnd('decorator') 106 | }) 107 | }) 108 | 109 | describe('makeObservable Alternative', () => { 110 | class Test { 111 | $a = 4 112 | $b = 3 113 | 114 | constructor() { 115 | makeObservable(this) 116 | } 117 | 118 | get $n(): number { 119 | return this.$a * this.$b 120 | } 121 | } 122 | 123 | test('some numbers', () => { 124 | const t = new Test() 125 | 126 | let result = 0 127 | 128 | $AutoRun(() => { 129 | result = t.$n 130 | }) 131 | 132 | expect(t.$a).toEqual(4) 133 | expect(t.$b).toEqual(3) 134 | expect(t.$n).toEqual(12) 135 | 136 | t.$a = 2 137 | 138 | expect(t.$a).toEqual(2) 139 | expect(t.$n).toEqual(6) 140 | }) 141 | }) 142 | 143 | describe('check that only correct fields are modified', () => { 144 | let computedRuns = 0 145 | 146 | class Test2 { 147 | $a = 2 148 | e = 9 149 | 150 | b(): number { 151 | return this.$a 152 | } 153 | 154 | constructor() { 155 | makeObservable(this) 156 | } 157 | 158 | get $c(): number { 159 | computedRuns++ 160 | return this.$a * this.$a 161 | } 162 | 163 | d(a: number): void { 164 | this.$a = a 165 | } 166 | } 167 | 168 | test('fields are as expected', () => { 169 | const t = new Test2() 170 | 171 | const entries = t as any 172 | expect(entries['__$a']).toBeDefined() 173 | expect(entries['__$c']).toBeDefined() 174 | expect(entries['__e']).toBeFalsy() 175 | expect(entries['__d']).toBeFalsy() 176 | 177 | expect(computedRuns).toEqual(0) 178 | expect(t.$c).toEqual(4) 179 | expect(computedRuns).toEqual(1) 180 | 181 | t.$a = 3 182 | expect(t.$c).toEqual(9) 183 | 184 | expect(computedRuns).toEqual(2) 185 | }) 186 | }) 187 | -------------------------------------------------------------------------------- /src/lib/observables/run-stack.ts: -------------------------------------------------------------------------------- 1 | import {UnorderedResponder} from './responder' 2 | import {$Component} from './$component' 3 | import {OArray} from '../util/o-array' 4 | import {RootComponent} from '../component-types/root-component' 5 | import {DomComponent} from '../component-types/host/dom-component' 6 | import {applyInserts} from '../util/order' 7 | import {RefObject} from '../util/ref' 8 | import {PureComponent} from '../..' 9 | import {time, timeEnd} from '../util/measure' 10 | 11 | export class RunStack { 12 | static computeds = new Set>() 13 | static reactions = new Set>() 14 | static components: $Component[] = [] 15 | 16 | private static running = false 17 | 18 | static insertsStack = new Set() 19 | static removeStack = new Set() 20 | 21 | static componentDidMountStack: RefObject[] = [] 22 | static componentDidUpdateStack: RefObject[] = [] 23 | 24 | // static depth = 0 25 | 26 | static runResponders( 27 | computeds: Set>, 28 | reactions: Set>, 29 | components: Map>, 30 | ) { 31 | // this.depth++ 32 | // console.log('depth: ', this.depth) 33 | 34 | // for (const [, c] of components) { 35 | for (const c of components.values()) { 36 | if (c.current !== null) OArray.insert(this.components, c.current) 37 | } 38 | for (const o of computeds) { 39 | if (o.current !== null) this.computeds.add(o) 40 | } 41 | for (const o of reactions) { 42 | if (o.current !== null) this.reactions.add(o) 43 | } 44 | 45 | this.run() 46 | 47 | // this.depth-- 48 | } 49 | 50 | static run() { 51 | if (!this.running) { 52 | this.running = true 53 | 54 | time('😈Reactions') 55 | while (this.computeds.size > 0 || this.reactions.size > 0) { 56 | while (this.computeds.size > 0) { 57 | const computed = this.computeds.values().next() 58 | .value as RefObject 59 | this.computeds.delete(computed) 60 | computed.current?.run() 61 | } 62 | while (this.reactions.size > 0) { 63 | const reaction = this.reactions.values().next() 64 | .value as RefObject 65 | this.reactions.delete(reaction) 66 | reaction.current?.run() 67 | } 68 | } 69 | timeEnd('😈Reactions') 70 | 71 | time('😈Render') 72 | while (this.components.length > 0) { 73 | const component = this.components.shift() 74 | component?.run() 75 | } 76 | timeEnd('😈Render') 77 | 78 | time('😈DOM') 79 | for (const e of this.removeStack) e.remove() 80 | this.removeStack.clear() 81 | for (const c of this.insertsStack) applyInserts(c) 82 | this.insertsStack.clear() 83 | timeEnd('😈DOM') 84 | 85 | time('😈Mount/Update') 86 | while (this.componentDidMountStack.length > 0) { 87 | const ref = this.componentDidMountStack.shift() 88 | ref?.current?.componentDidMount() 89 | } 90 | while (this.componentDidUpdateStack.length > 0) { 91 | const ref = this.componentDidUpdateStack.shift() 92 | ref?.current?.componentDidUpdate() 93 | } 94 | timeEnd('😈Mount/Update') 95 | 96 | this.running = false 97 | 98 | if (!this.empty()) this.run() 99 | 100 | // if (__DEV__) { 101 | // console.log( 102 | // this.computeds.size, 103 | // this.reactions.size, 104 | // this.components.length, 105 | // this.insertsStack.size, 106 | // this.removeStack.size, 107 | // this.componentDidMountStack.length, 108 | // this.componentDidUpdateStack.length 109 | // ) 110 | // } 111 | } 112 | } 113 | 114 | static empty(): boolean { 115 | return ( 116 | this.computeds.size === 0 && 117 | this.reactions.size === 0 && 118 | this.components.length === 0 && 119 | this.insertsStack.size === 0 && 120 | this.removeStack.size === 0 && 121 | this.componentDidMountStack.length === 0 && 122 | this.componentDidUpdateStack.length === 0 123 | ) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/lib/component-types/host/dom-component.test.ts: -------------------------------------------------------------------------------- 1 | import {Div} from './dom-components' 2 | import {PureComponent} from '../pure-component' 3 | import {FiendNode} from '../../..' 4 | import {ElementNamespace, ElementType} from '../../util/element' 5 | import {mkRoot} from '../../../dom-tests/test-helpers' 6 | 7 | describe('div renders', () => { 8 | test('no args', () => { 9 | const el = Div({children: []}) 10 | 11 | expect(el).toEqual({ 12 | elementType: ElementType.dom, 13 | _type: 'div', 14 | namespace: ElementNamespace.html, 15 | props: {children: []}, 16 | }) 17 | }) 18 | 19 | test('single element arg', () => { 20 | const el = Div('hello') 21 | 22 | expect(el).toEqual({ 23 | elementType: ElementType.dom, 24 | _type: 'div', 25 | namespace: ElementNamespace.html, 26 | props: {children: ['hello']}, 27 | }) 28 | }) 29 | 30 | test('single attribute arg', () => { 31 | const el = Div({className: 'hi'}) 32 | 33 | expect(el).toEqual({ 34 | elementType: ElementType.dom, 35 | _type: 'div', 36 | namespace: ElementNamespace.html, 37 | props: {className: 'hi'}, 38 | }) 39 | }) 40 | 41 | test('both attributes and elements', () => { 42 | const el = Div({className: 'hi', children: ['hello']}) 43 | 44 | expect(el).toEqual({ 45 | elementType: ElementType.dom, 46 | _type: 'div', 47 | namespace: ElementNamespace.html, 48 | props: {className: 'hi', children: ['hello']}, 49 | }) 50 | }) 51 | 52 | test('two elements only', () => { 53 | const el = Div({children: ['hello', 'hello']}) 54 | 55 | expect(el).toEqual({ 56 | elementType: ElementType.dom, 57 | _type: 'div', 58 | namespace: ElementNamespace.html, 59 | props: {children: ['hello', 'hello']}, 60 | }) 61 | }) 62 | 63 | test('render to dom', () => { 64 | const root = mkRoot() 65 | 66 | root.render(Div({children: ['omg']})) 67 | 68 | expect(root.element.innerHTML).toEqual(`
omg
`) 69 | }) 70 | 71 | test('custom component', () => { 72 | const root = mkRoot() 73 | 74 | root.render(A.$({})) 75 | 76 | expect(root.element.innerHTML).toEqual(`
omg
`) 77 | }) 78 | }) 79 | 80 | class A extends PureComponent { 81 | render(): FiendNode { 82 | return Div('omg') 83 | } 84 | } 85 | 86 | // describe('isPropsObject', () => { 87 | // test('number', () => { 88 | // expect(isPropsObject(3)).toEqual(false) 89 | // 90 | // const n = 12 91 | // 92 | // expect(isPropsObject(n)).toEqual(false) 93 | // }) 94 | // 95 | // test('strings', () => { 96 | // expect(isPropsObject('omg')).toEqual(false) 97 | // }) 98 | // 99 | // test('null', () => { 100 | // expect(isPropsObject(null)).toEqual(false) 101 | // expect(isPropsObject(undefined)).toEqual(false) 102 | // }) 103 | // 104 | // test('host components', () => { 105 | // expect(isPropsObject(div({}))).toEqual(false) 106 | // }) 107 | // 108 | // test('custom components', () => { 109 | // class A extends Component { 110 | // render(): Subtree { 111 | // return div('omg') 112 | // } 113 | // } 114 | // 115 | // expect(isPropsObject(A)).toEqual(false) 116 | // expect(isPropsObject(A.$({}))).toEqual(false) 117 | // }) 118 | // 119 | // test('possible objects', () => { 120 | // expect(isPropsObject({})).toEqual(true) 121 | // expect(isPropsObject({style: s`height: 10px`})).toEqual(true) 122 | // }) 123 | // }) 124 | 125 | // describe('test construction perf', () => { 126 | // const numLoops = 1000000 127 | // test('div2', () => { 128 | // timeF(() => { 129 | // let elements: Tree[] = [] 130 | // 131 | // for (let i = 0; i < numLoops; i++) { 132 | // elements.push(div2({style: {width: '100px'}}, div2(), div2(), div2())) 133 | // } 134 | // 135 | // expect(elements.length).toEqual(numLoops) 136 | // }, 'div2') 137 | // }) 138 | // test('div', () => { 139 | // timeF(() => { 140 | // let elements: Tree[] = [] 141 | // 142 | // for (let i = 0; i < numLoops; i++) { 143 | // elements.push(div({style: {width: '100px'}}, div(), div(), div())) 144 | // } 145 | // 146 | // expect(elements.length).toEqual(numLoops) 147 | // }, 'div') 148 | // }) 149 | // }) 150 | -------------------------------------------------------------------------------- /src/lib/create-element.test.ts: -------------------------------------------------------------------------------- 1 | import {Div, H1, P} from './component-types/host/dom-components' 2 | import {ElementNamespace, ElementType} from './util/element' 3 | 4 | // const numLoops = 300000 5 | 6 | describe('create element', () => { 7 | // test(`run createElement ${numLoops} times`, () => { 8 | // testFunc(createElement) 9 | // 10 | // expect(true).toBe(true) 11 | // }) 12 | 13 | test('1 child', () => { 14 | const d = H1('Heading') 15 | 16 | expect(d).toEqual({ 17 | _type: 'h1', 18 | elementType: ElementType.dom, 19 | namespace: ElementNamespace.html, 20 | props: {children: ['Heading']}, 21 | }) 22 | }) 23 | 24 | test('2 children', () => { 25 | const d = Div({children: [H1('Heading'), P('paragraph')]}) 26 | 27 | expect(d).toEqual({ 28 | _type: 'div', 29 | elementType: ElementType.dom, 30 | namespace: ElementNamespace.html, 31 | props: { 32 | children: [ 33 | { 34 | _type: 'h1', 35 | elementType: ElementType.dom, 36 | namespace: ElementNamespace.html, 37 | props: { 38 | children: ['Heading'], 39 | }, 40 | }, 41 | { 42 | _type: 'p', 43 | elementType: ElementType.dom, 44 | namespace: ElementNamespace.html, 45 | props: { 46 | children: ['paragraph'], 47 | }, 48 | }, 49 | ], 50 | }, 51 | }) 52 | }) 53 | 54 | test('child array', () => { 55 | const d = Div({children: [...[1, 2, 3].map(n => Div(`${n}`))]}) 56 | 57 | expect(d).toEqual({ 58 | _type: 'div', 59 | elementType: ElementType.dom, 60 | namespace: ElementNamespace.html, 61 | props: { 62 | children: [ 63 | { 64 | _type: 'div', 65 | elementType: ElementType.dom, 66 | namespace: ElementNamespace.html, 67 | props: { 68 | children: ['1'], 69 | }, 70 | }, 71 | { 72 | _type: 'div', 73 | elementType: ElementType.dom, 74 | namespace: ElementNamespace.html, 75 | props: { 76 | children: ['2'], 77 | }, 78 | }, 79 | { 80 | _type: 'div', 81 | elementType: ElementType.dom, 82 | namespace: ElementNamespace.html, 83 | props: { 84 | children: ['3'], 85 | }, 86 | }, 87 | ], 88 | }, 89 | }) 90 | }) 91 | 92 | test('child array mixed', () => { 93 | const d = Div({children: [H1('Heading'), ...[1, 2, 3].map(n => Div(`${n}`))]}) 94 | 95 | expect(d).toEqual({ 96 | _type: 'div', 97 | elementType: ElementType.dom, 98 | namespace: ElementNamespace.html, 99 | props: { 100 | children: [ 101 | { 102 | _type: 'h1', 103 | elementType: ElementType.dom, 104 | namespace: ElementNamespace.html, 105 | props: { 106 | children: ['Heading'], 107 | }, 108 | }, 109 | { 110 | _type: 'div', 111 | elementType: ElementType.dom, 112 | namespace: ElementNamespace.html, 113 | props: { 114 | children: ['1'], 115 | }, 116 | }, 117 | { 118 | _type: 'div', 119 | elementType: ElementType.dom, 120 | namespace: ElementNamespace.html, 121 | props: { 122 | children: ['2'], 123 | }, 124 | }, 125 | { 126 | _type: 'div', 127 | elementType: ElementType.dom, 128 | namespace: ElementNamespace.html, 129 | props: { 130 | children: ['3'], 131 | }, 132 | }, 133 | ], 134 | }, 135 | }) 136 | }) 137 | }) 138 | 139 | // function testFunc(f: Function) { 140 | // const a: any[] = [] 141 | // 142 | // // noinspection JSUnusedLocalSymbols 143 | // const createElement = f 144 | // 145 | // console.time(f.name) 146 | // for (let i = 0; i < numLoops; i++) { 147 | // a.push(
Hello
) 148 | // } 149 | // console.timeEnd(f.name) 150 | // console.log(a.slice(0, 1)) 151 | // } 152 | -------------------------------------------------------------------------------- /src/lib/component-types/keys.test.ts: -------------------------------------------------------------------------------- 1 | import {PureComponent} from './pure-component' 2 | import {Div} from './host/dom-components' 3 | import {FiendNode} from '../..' 4 | import {mkRoot} from '../../dom-tests/test-helpers' 5 | 6 | describe('test re-rendering keyed lists', () => { 7 | test('plain divs with keys', () => { 8 | const num = 20 9 | 10 | class Scroller extends PureComponent<{n: number}> { 11 | render() { 12 | const {n} = this.props 13 | 14 | const elements: FiendNode[] = [] 15 | 16 | for (let i = 0; i < num; i++) { 17 | const s = `${n + i}` 18 | 19 | elements.push( 20 | Div({ 21 | children: [s], 22 | key: s, 23 | }), 24 | ) 25 | } 26 | 27 | return elements 28 | } 29 | } 30 | 31 | const root = mkRoot() 32 | 33 | let n = 0 34 | root.render(Scroller.$({n})) 35 | expect(root.inserted.length).toEqual(num) 36 | expect(root.html).toEqual(result(n, num)) 37 | 38 | n = 10 39 | root.render(Scroller.$({n})) 40 | expect(root.inserted.length).toEqual(num) 41 | expect(root.html).toEqual(result(n, num)) 42 | 43 | n = 5 44 | root.render(Scroller.$({n})) 45 | expect(root.inserted.length).toEqual(num) 46 | expect(root.html).toEqual(result(n, num)) 47 | }) 48 | 49 | test('divs without keys', () => { 50 | const num = 4 51 | 52 | class Scroller extends PureComponent<{n: number}> { 53 | render() { 54 | const {n} = this.props 55 | 56 | const elements: FiendNode[] = [] 57 | 58 | for (let i = 0; i < num; i++) { 59 | const s = `${n + i}` 60 | 61 | elements.push( 62 | Div({ 63 | children: [s], 64 | }), 65 | ) 66 | } 67 | 68 | return elements 69 | } 70 | } 71 | 72 | const root = mkRoot() 73 | 74 | let n = 0 75 | root.render(Scroller.$({n})) 76 | expect(root.inserted.length).toEqual(num) 77 | expect(root.html).toEqual(result(n, num)) 78 | 79 | // Scroll forward 80 | n = 6 81 | root.render(Scroller.$({n})) 82 | expect(root.inserted.length).toEqual(num) 83 | expect(root.html).toEqual(result(n, num)) 84 | 85 | // console.log(result(n, num)) 86 | 87 | // Scroll backward 88 | n = 5 89 | root.render(Scroller.$({n})) 90 | expect(root.inserted.length).toEqual(num) 91 | expect(root.html).toEqual(result(n, num)) 92 | 93 | // Scroll backward 94 | n = 4 95 | root.render(Scroller.$({n})) 96 | expect(root.inserted.length).toEqual(num) 97 | expect(root.html).toEqual(result(n, num)) 98 | 99 | // Scroll backward 100 | n = 3 101 | root.render(Scroller.$({n})) 102 | expect(root.inserted.length).toEqual(num) 103 | expect(root.html).toEqual(result(n, num)) 104 | }) 105 | 106 | test('wrapped divs', () => { 107 | const num = 3 108 | 109 | class DivC extends PureComponent<{text: string}> { 110 | render() { 111 | return Div({children: [this.props.text]}) 112 | } 113 | } 114 | 115 | class Scroller extends PureComponent<{n: number}> { 116 | render() { 117 | const {n} = this.props 118 | 119 | const elements: FiendNode[] = [] 120 | 121 | for (let i = 0; i < num; i++) { 122 | const s = `${n + i}` 123 | 124 | elements.push( 125 | DivC.$({ 126 | text: s, 127 | key: s, 128 | }), 129 | ) 130 | } 131 | 132 | return elements 133 | } 134 | } 135 | 136 | const root = mkRoot() 137 | 138 | let n = 0 139 | root.render(Scroller.$({n})) 140 | expect(root.inserted.length).toEqual(num) 141 | expect(root.html).toEqual(result(n, num)) 142 | 143 | n = 9 144 | root.render(Scroller.$({n})) 145 | expect(root.inserted.length).toEqual(num) 146 | expect(root.html).toEqual(result(n, num)) 147 | 148 | n = 8 149 | root.render(Scroller.$({n})) 150 | expect(root.inserted.length).toEqual(num) 151 | expect(root.html).toEqual(result(n, num)) 152 | }) 153 | }) 154 | 155 | const result = (index: number, numDivs: number): string => { 156 | let r = '' 157 | 158 | for (let i = 0; i < numDivs; i++) { 159 | r += `
${index + i}
` 160 | } 161 | return r 162 | } 163 | -------------------------------------------------------------------------------- /src/dom-tests/deletion.test.ts: -------------------------------------------------------------------------------- 1 | import {PureComponent} from '..' 2 | import {render} from '..' 3 | import {Div, H1} from '..' 4 | import {FiendNode} from '..' 5 | import {mkRoot} from './test-helpers' 6 | 7 | describe('deletion of custom component', () => { 8 | test('host inside host', () => { 9 | const root = mkRoot() 10 | 11 | const c = Div({children: [Div('a'), Div('b')]}) 12 | 13 | root.render(c) 14 | 15 | expect(root.element.innerHTML).toEqual(`
a
b
`) 16 | 17 | const c2 = Div({children: [Div('a')]}) 18 | 19 | root.render(c2) 20 | 21 | expect(root.element.innerHTML).toEqual(`
a
`) 22 | 23 | const c3 = Div({children: [Div('a'), Div('b')]}) 24 | 25 | root.render(c3) 26 | 27 | expect(root.element.innerHTML).toEqual(`
a
b
`) 28 | }) 29 | 30 | test('host inside custom', () => { 31 | const root = mkRoot() 32 | 33 | const c = C.$({children: [Div('a'), Div('b')]}) 34 | 35 | root.render(c) 36 | 37 | expect(root.element.innerHTML).toEqual(`
a
b
`) 38 | 39 | const c2 = C.$({children: [Div('a')]}) 40 | 41 | root.render(c2) 42 | 43 | expect(root.element.innerHTML).toEqual(`
a
`) 44 | 45 | const c3 = C.$({children: [Div('a'), Div('b')]}) 46 | 47 | root.render(c3) 48 | 49 | expect(root.element.innerHTML).toEqual(`
a
b
`) 50 | }) 51 | 52 | test('custom inside custom', () => { 53 | const root = mkRoot() 54 | 55 | let c = C.$({children: [C.$({children: ['a']}), C.$({children: ['b']})]}) 56 | 57 | root.render(c) 58 | // let divs = renderTree(c, null, root.order, 0) 59 | 60 | expect(root.element.innerHTML).toEqual(`
a
b
`) 61 | 62 | c = C.$({children: [C.$({children: ['a']})]}) 63 | 64 | root.render(c) 65 | // divs = renderTree(c, divs, root.order, 0) 66 | 67 | expect(root.element.innerHTML).toEqual(`
a
`) 68 | 69 | c = C.$({children: [C.$({children: ['a']}), C.$({children: ['b']})]}) 70 | 71 | root.render(c) 72 | // renderTree(c, divs, root.order, 0) 73 | 74 | expect(root.element.innerHTML).toEqual(`
a
b
`) 75 | }) 76 | 77 | test('custom inside custom with keys', () => { 78 | const root = mkRoot() 79 | 80 | let c = C.$({ 81 | children: [C.$({children: ['a'], key: 'a'}), C.$({children: ['b'], key: 'b'})], 82 | }) 83 | 84 | root.render(c) 85 | // let divs = renderTree(c, null, root.order, 0) 86 | 87 | expect(root.element.innerHTML).toEqual(`
a
b
`) 88 | 89 | c = C.$({children: [C.$({children: ['a'], key: 'a'})]}) 90 | 91 | root.render(c) 92 | // divs = renderTree(c, divs, root.order, 0) 93 | 94 | expect(root.element.innerHTML).toEqual(`
a
`) 95 | 96 | c = C.$({ 97 | children: [C.$({children: ['a'], key: 'a'}), C.$({children: ['b'], key: 'b'})], 98 | }) 99 | 100 | root.render(c) 101 | // renderTree(c, divs, root.order, 0) 102 | 103 | expect(root.element.innerHTML).toEqual(`
a
b
`) 104 | }) 105 | 106 | // xtest('complex', () => { 107 | // const root = mkRoot() 108 | // 109 | // const c = ( 110 | // 111 | //

a

112 | // {pickComponent('a')} 113 | //
114 | // ) 115 | // 116 | // const divs = renderTree(c, null, root, 0) 117 | // 118 | // expect(root.element.innerHTML).toEqual(`

a

A
`) 119 | // 120 | // const c2 = ( 121 | // 122 | //

a

123 | // {pickComponent('b')} 124 | //
125 | // ) 126 | // 127 | // renderTree(c2, divs, root, 0) 128 | // 129 | // expect(root.element.innerHTML).toEqual(`

a

B
`) 130 | // }) 131 | 132 | test('different order', () => { 133 | class Outer extends PureComponent { 134 | render() { 135 | return [A.$({}), H1('Heading')] 136 | } 137 | } 138 | 139 | const divElement = document.createElement('div') 140 | render(Outer.$({}), divElement) 141 | 142 | expect(divElement.innerHTML).toEqual(`
A

Heading

`) 143 | }) 144 | }) 145 | 146 | class C extends PureComponent { 147 | render(): FiendNode { 148 | const {children = []} = this.props 149 | 150 | return Div({children}) 151 | } 152 | } 153 | 154 | class A extends PureComponent { 155 | render(): FiendNode | null { 156 | return Div('A') 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/lib/observables/decorator.test.ts: -------------------------------------------------------------------------------- 1 | import {autorun, computed, IReactionDisposer, observable} from 'mobx' 2 | import {$AutoRun} from './responder' 3 | import {globalStack} from './global-stack' 4 | import {$Val} from './value-style' 5 | 6 | function ob(value: T): {(): T; (newValue: T): undefined} { 7 | const obs = observable.box(value) 8 | 9 | function inner(): T 10 | function inner(newValue: T): undefined 11 | function inner(newValue?: T) { 12 | if (arguments.length === 0) return obs.get() 13 | 14 | if (newValue !== undefined) obs.set(newValue) 15 | 16 | return undefined 17 | } 18 | 19 | return inner 20 | } 21 | 22 | function computed2(f: () => T) { 23 | const c = computed(f) 24 | 25 | return () => { 26 | return c.get() 27 | } 28 | } 29 | 30 | xdescribe('o', () => { 31 | test('o', () => { 32 | const n = ob(5) 33 | let ok = false 34 | 35 | autorun(() => { 36 | console.log('n: ', n()) 37 | 38 | if (n() === 6) { 39 | ok = true 40 | } 41 | }) 42 | 43 | n(6) 44 | 45 | expect(ok).toBeTruthy() 46 | }) 47 | }) 48 | 49 | describe('mobx behaviour', () => { 50 | test('autorun supports setting', () => { 51 | const a = observable.box(2) 52 | const b = observable.box(2) 53 | const c = computed(() => a.get() * b.get()) 54 | let d: number = c.get() 55 | 56 | expect(c.get()).toEqual(4) 57 | let count = 0 58 | 59 | autorun(() => { 60 | count++ 61 | b.set(a.get() * 5) 62 | 63 | expect(b.get()).toEqual(10) 64 | expect(c.get()).toEqual(20) 65 | 66 | d = c.get() 67 | }) 68 | 69 | expect(count).toEqual(1) 70 | }) 71 | }) 72 | 73 | xdescribe('test decorator perf', () => { 74 | const loops = 1000 75 | 76 | test('time a', () => { 77 | class A { 78 | @observable 79 | a = 5 80 | 81 | @observable 82 | b = 5 83 | 84 | @observable 85 | c = 5 86 | 87 | @observable 88 | d = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 89 | } 90 | 91 | class RunA { 92 | a = new A() 93 | 94 | disposer: IReactionDisposer 95 | 96 | constructor() { 97 | this.disposer = autorun(() => { 98 | const {a, b, c, d} = this.a 99 | 100 | d.push(a, b, c) 101 | }) 102 | } 103 | } 104 | 105 | console.time('a') 106 | for (let i = 0; i < loops; i++) { 107 | const a = new RunA() 108 | a.disposer() 109 | } 110 | console.timeEnd('a') 111 | }) 112 | 113 | test('time b', () => { 114 | class B { 115 | a = observable.box(5) 116 | 117 | b = observable.box(5) 118 | 119 | c = observable.box(5) 120 | 121 | d = observable([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 122 | } 123 | 124 | class RunB { 125 | b = new B() 126 | 127 | disposer: IReactionDisposer 128 | 129 | constructor() { 130 | this.disposer = autorun(() => { 131 | const {a, b, c, d} = this.b 132 | 133 | d.push(a.get(), b.get(), c.get()) 134 | }) 135 | } 136 | } 137 | 138 | console.time('b') 139 | for (let i = 0; i < loops; i++) { 140 | const b = new RunB() 141 | b.disposer() 142 | } 143 | console.timeEnd('b') 144 | }) 145 | 146 | test('C', () => { 147 | function mkC() { 148 | return observable({ 149 | a: 5, 150 | b: 5, 151 | c: 5, 152 | d: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 153 | }) 154 | } 155 | 156 | class RunC { 157 | c = mkC() 158 | 159 | disposer: IReactionDisposer 160 | 161 | constructor() { 162 | this.disposer = autorun(() => { 163 | const {a, b, c, d} = this.c 164 | 165 | d.push(a, b, c) 166 | }) 167 | } 168 | } 169 | 170 | console.time('c') 171 | for (let i = 0; i < loops; i++) { 172 | const c = new RunC() 173 | c.disposer() 174 | } 175 | console.timeEnd('c') 176 | }) 177 | 178 | test('d', () => { 179 | class D { 180 | a = ob(5) 181 | 182 | b = ob(5) 183 | 184 | c = ob(5) 185 | 186 | res = computed2(() => this.a() + this.b() + this.c()) 187 | 188 | d = observable.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 189 | } 190 | 191 | class RunD { 192 | b = new D() 193 | 194 | disposer: IReactionDisposer 195 | 196 | constructor() { 197 | this.disposer = autorun(() => { 198 | const {a, b, c, d} = this.b 199 | 200 | d.push(a(), b(), c()) 201 | }) 202 | } 203 | } 204 | 205 | console.time('d') 206 | for (let i = 0; i < loops; i++) { 207 | const d = new RunD() 208 | 209 | console.log(d.b.res()) 210 | d.disposer() 211 | } 212 | console.timeEnd('d') 213 | }) 214 | 215 | test('e', () => { 216 | class E { 217 | a = $Val(5) 218 | 219 | b = $Val(5) 220 | 221 | c = $Val(5) 222 | 223 | d = $Val([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 224 | } 225 | 226 | class Run { 227 | b = new E() 228 | 229 | constructor() { 230 | $AutoRun(() => { 231 | const {a, b, c, d} = this.b 232 | 233 | const array = [...d()] 234 | 235 | array.push(a(), b(), c()) 236 | 237 | d(array) 238 | }) 239 | } 240 | } 241 | 242 | console.time('e') 243 | for (let i = 0; i < loops; i++) { 244 | new Run() 245 | } 246 | 247 | console.timeEnd('e') 248 | }) 249 | }) 250 | -------------------------------------------------------------------------------- /src/lib/component-types/pure-component.ts: -------------------------------------------------------------------------------- 1 | import type {CustomElement, FiendNode, StandardProps} from '../util/element' 2 | import {ElementType} from '../util/element' 3 | import { 4 | AnyComponent, 5 | ComponentBase, 6 | ComponentType, 7 | ParentComponent, 8 | } from './base-component' 9 | import {Render} from '../render' 10 | import {time, timeEnd} from '../util/measure' 11 | import {Order} from '../util/order' 12 | import {DomComponent} from './host/dom-component' 13 | import {RootComponent} from './root-component' 14 | import {RefObject} from '../util/ref' 15 | import {RunStack} from '../observables/run-stack' 16 | 17 | export interface Rec { 18 | [prop: string]: unknown 19 | } 20 | 21 | export type PropsWithChildren = T & StandardProps 22 | 23 | // P = {} to simply prop type definitions. 24 | export abstract class PureComponent

25 | implements ComponentBase 26 | { 27 | _type = ComponentType.custom as const 28 | props: PropsWithChildren

29 | order: string 30 | key: string 31 | 32 | subComponents = new Map() 33 | 34 | _ref: RefObject = { 35 | current: this, 36 | } 37 | 38 | constructor( 39 | props: P, 40 | public domParent: DomComponent | RootComponent, 41 | directParent: ParentComponent, 42 | // TODO: Is this safe? It doesn't get updated? So could be reassigned accidentally? 43 | public index: number, 44 | ) { 45 | this.props = props 46 | this.order = Order.key(directParent.order, index) 47 | this.key = this.props.key ?? directParent.key + index 48 | } 49 | 50 | abstract render(): FiendNode | FiendNode[] 51 | 52 | update() { 53 | if (__DEV__ && this.removed) { 54 | throw 'Called update after removed!' 55 | } 56 | if (__DEV__) { 57 | time(this.constructor.name) 58 | } 59 | const res = this.render() 60 | 61 | this.subComponents = Render.subComponents( 62 | this.domParent, 63 | this, 64 | Array.isArray(res) ? res : [res], 65 | this.subComponents, 66 | ) 67 | 68 | if (__DEV__) { 69 | timeEnd(this.constructor.name) 70 | } 71 | } 72 | 73 | // noinspection JSUnusedGlobalSymbols 74 | updateWithNewProps(props: PropsWithChildren

): void { 75 | if (!equalProps(this.props, props)) { 76 | this.props = props 77 | this.update() 78 | RunStack.componentDidUpdateStack.push(this._ref) 79 | } 80 | } 81 | 82 | componentDidMount(): void {} 83 | 84 | componentDidUpdate(): void {} 85 | 86 | componentWillUnmount(): void {} 87 | 88 | forceUpdate = () => { 89 | // Sometimes we put a forceUpdate inside a setTimeout, we don't want it 90 | // to run if this element has been removed before it runs. 91 | if (this.removed) return 92 | 93 | // Doesn't componentDidUpdate get called after update? 94 | this.update() 95 | } 96 | 97 | mount() { 98 | this.update() 99 | RunStack.componentDidMountStack.push(this._ref) 100 | } 101 | 102 | removed = false 103 | 104 | remove(): void { 105 | if (__DEV__ && this.removed) { 106 | console.error('already removed') 107 | } 108 | this.componentWillUnmount() 109 | 110 | for (const c of this.subComponents.values()) c.remove() 111 | this.subComponents.clear() 112 | this.removed = true 113 | } 114 | 115 | static $( 116 | this: new (...args: never[]) => T, 117 | props: T['props'], 118 | ): CustomElement { 119 | return { 120 | _type: this as any, 121 | elementType: ElementType.custom, 122 | props, 123 | } 124 | } 125 | } 126 | 127 | export function renderCustom( 128 | tree: CustomElement, 129 | prev: AnyComponent | null, 130 | domParent: DomComponent | RootComponent, 131 | directParent: ParentComponent, 132 | index: number, 133 | ) { 134 | const {_type, props} = tree 135 | 136 | if (prev === null) { 137 | return makeCustomComponent(_type, props, domParent, directParent, index) 138 | } 139 | 140 | if (prev._type === ComponentType.custom && prev instanceof _type) { 141 | const newOrder = Order.key(directParent.order, index) 142 | const prevOrder = prev.order 143 | 144 | if (newOrder !== prevOrder) { 145 | prev.index = index 146 | prev.order = newOrder 147 | 148 | for (const c of prev.subComponents.values()) { 149 | const no = Order.key(prev.order, c.index) 150 | 151 | if (c.order !== no) { 152 | c.order = no 153 | 154 | switch (c._type) { 155 | case ComponentType.host: 156 | case ComponentType.text: 157 | domParent.moveChild(c) 158 | break 159 | case ComponentType.custom: 160 | c.update() 161 | break 162 | } 163 | } 164 | } 165 | } 166 | 167 | prev.updateWithNewProps(props) 168 | 169 | return prev 170 | } 171 | 172 | prev.remove() 173 | 174 | return makeCustomComponent(_type, props, domParent, directParent, index) 175 | } 176 | 177 | export type CustomComponent

= new

( 178 | props: P, 179 | parentHost: DomComponent | RootComponent, 180 | directParent: ParentComponent, 181 | index: number, 182 | ) => PureComponent 183 | 184 | function makeCustomComponent

( 185 | cons: CustomComponent

, 186 | props: P, 187 | parentHost: DomComponent | RootComponent, 188 | directParent: ParentComponent, 189 | index: number, 190 | ) { 191 | const component = new cons

(props, parentHost, directParent, index) 192 | component.mount() 193 | 194 | return component 195 | } 196 | 197 | export function equalProps(a: object, b: object): boolean { 198 | const aKeys = Object.keys(a) 199 | const bKeys = Object.keys(b) 200 | 201 | if (aKeys.length !== bKeys.length) return false 202 | 203 | // We should only need to loop over aKeys since the length must be the same. 204 | for (const key of aKeys) { 205 | // @ts-ignore 206 | if (a[key] !== b[key]) return false 207 | } 208 | 209 | return true 210 | } 211 | -------------------------------------------------------------------------------- /src/lib/misc/compare-speed.test.ts: -------------------------------------------------------------------------------- 1 | import {$AutoRun, $Reaction} from '../observables/responder' 2 | import {makeObservable} from '../observables/$model' 3 | import {autorun, computed, observable} from 'mobx' 4 | import {$Calc, $Val} from '../observables/value-style' 5 | 6 | describe('reaction tests', () => { 7 | test('basic reaction', () => { 8 | const n = $Val(4) 9 | 10 | const sqr = $Calc(() => n() * n()) 11 | 12 | let out = 0 13 | $Reaction( 14 | () => sqr(), 15 | result => { 16 | out = result 17 | } 18 | ) 19 | 20 | n(2) 21 | expect(out).toEqual(4) 22 | 23 | n(3) 24 | expect(out).toEqual(9) 25 | }) 26 | }) 27 | 28 | xdescribe('proxy test', () => { 29 | type Target = {message2: string; message1: string} 30 | 31 | const target: Target = { 32 | message1: 'hello', 33 | message2: 'everyone', 34 | } 35 | 36 | const handler2 = { 37 | get(target: Target, prop: keyof Target) { 38 | return target[prop] 39 | }, 40 | } 41 | 42 | const proxy = new Proxy(target, handler2) 43 | 44 | const loops = 1000000 45 | 46 | test('proxy speed', () => { 47 | console.time('proxy') 48 | 49 | for (let i = 0; i < loops; i++) { 50 | const m1 = proxy.message1 51 | const m2 = proxy.message2 52 | } 53 | 54 | console.timeEnd('proxy') 55 | }) 56 | 57 | test('normal access speed', () => { 58 | console.time('normy') 59 | 60 | for (let i = 0; i < loops; i++) { 61 | const m1 = target.message1 62 | const m2 = target.message2 63 | } 64 | 65 | console.timeEnd('normy') 66 | }) 67 | }) 68 | 69 | xdescribe('construction speed', () => { 70 | const num = 1_000_000 71 | 72 | test('time things', () => { 73 | console.time('array') 74 | let a 75 | for (let i = 0; i < num; i++) { 76 | a = [i] 77 | } 78 | console.timeEnd('array') 79 | 80 | console.time('array2') 81 | let d 82 | for (let i = 0; i < num; i++) { 83 | d = Array.from([]) 84 | } 85 | console.timeEnd('array2') 86 | 87 | console.time('object') 88 | let b 89 | for (let i = 0; i < num; i++) { 90 | b = {a: i} 91 | } 92 | console.timeEnd('object') 93 | 94 | console.time('map') 95 | let c 96 | for (let i = 0; i < num; i++) { 97 | c = new Map() 98 | } 99 | console.timeEnd('map') 100 | 101 | expect(true).toBeTruthy() 102 | }) 103 | }) 104 | 105 | xdescribe('map vs object', () => { 106 | const o = { 107 | a: 1, 108 | b: 2, 109 | c: 3, 110 | d: 4, 111 | e: 5, 112 | f: 6, 113 | g: 7, 114 | h: 8, 115 | i: 9, 116 | j: 10, 117 | k: 11, 118 | l: 12, 119 | } 120 | 121 | const m = new Map() 122 | 123 | m.set('a', 1) 124 | m.set('b', 2) 125 | m.set('c', 3) 126 | m.set('d', 4) 127 | m.set('e', 5) 128 | m.set('f', 6) 129 | m.set('g', 7) 130 | m.set('h', 8) 131 | m.set('i', 9) 132 | m.set('j', 10) 133 | m.set('k', 11) 134 | m.set('l', 12) 135 | 136 | const num = 100_000 137 | 138 | test('object', () => { 139 | let n = 0 140 | 141 | console.time('object') 142 | 143 | for (let i = 0; i < num; i++) { 144 | // const keys = Object.keys(o) as (keyof typeof o)[] 145 | // 146 | // for (const key of keys) { 147 | // n = o[key] 148 | // } 149 | 150 | for (const key in o) { 151 | n = o[key as keyof typeof o] 152 | } 153 | } 154 | 155 | console.timeEnd('object') 156 | 157 | expect(n).toEqual(12) 158 | }) 159 | 160 | test('map', () => { 161 | let n = 0 162 | 163 | console.time('map') 164 | 165 | for (let i = 0; i < num; i++) { 166 | for (const [, value] of m) { 167 | n = value 168 | } 169 | } 170 | 171 | console.timeEnd('map') 172 | 173 | expect(n).toEqual(12) 174 | }) 175 | }) 176 | 177 | describe('$AutoRun cleanup', () => { 178 | let count = 0 179 | const n = $Val(1) 180 | let dispose: () => void 181 | 182 | test('autorun', () => { 183 | dispose = $AutoRun(() => { 184 | n() 185 | count++ 186 | }) 187 | 188 | expect(count).toEqual(1) 189 | n(2) 190 | expect(count).toEqual(2) 191 | }) 192 | 193 | test('out of scope, no cleanup', () => { 194 | // a.end() 195 | n(3) 196 | expect(count).toEqual(3) 197 | }) 198 | 199 | test('out of scope, with cleanup', () => { 200 | dispose() 201 | n(4) 202 | expect(count).toEqual(3) 203 | }) 204 | }) 205 | 206 | describe('reaction scope', () => { 207 | let count = 0 208 | const n = $Val(1) 209 | 210 | test('init reaction', () => { 211 | let i = 0 212 | 213 | const disposer = $AutoRun(() => { 214 | i = n() 215 | count++ 216 | }) 217 | 218 | expect(count).toEqual(1) 219 | n(n() + 1) 220 | expect(count).toEqual(2) 221 | 222 | disposer() 223 | }) 224 | 225 | test('out of scope', () => { 226 | const before = count 227 | 228 | n(n() + 1) 229 | n(n() + 1) 230 | n(n() + 1) 231 | n(n() + 1) 232 | expect(count).toEqual(before) 233 | }) 234 | }) 235 | 236 | /* 237 | When a class with a computed goes out of scope we want any computeds referencing 238 | external observables to stop running. 239 | */ 240 | describe('computed scope', () => { 241 | let count = 0 242 | 243 | const n = $Val(1) 244 | 245 | /* 246 | The computed is inside n's responder list. The computed doesn't have any 247 | responders. If it doesn't have any responders, then it shouldn't be inside n's list? 248 | 249 | When n changes, it calls $num's run method. $num has no responders. It shouldn't run unless 250 | it's called via a get(). 251 | */ 252 | 253 | class A { 254 | constructor() { 255 | makeObservable(this) 256 | } 257 | 258 | get $num(): number { 259 | count++ 260 | return n() * n() 261 | } 262 | } 263 | 264 | test('init computed', () => { 265 | const a = new A() 266 | 267 | const d = $AutoRun(() => { 268 | a.$num 269 | }) 270 | 271 | expect(count).toEqual(1) 272 | n(n() + 1) 273 | expect(count).toEqual(2) 274 | 275 | d() 276 | }) 277 | 278 | test('out of scope', () => { 279 | const before = count 280 | 281 | n(n() + 1) 282 | n(n() + 1) 283 | n(n() + 1) 284 | n(n() + 1) 285 | 286 | expect(count).toEqual(before) 287 | }) 288 | }) 289 | 290 | describe('mobx computed scope', () => { 291 | let count = 0 292 | 293 | const n = observable.box(1) 294 | 295 | class A { 296 | @computed 297 | get num(): number { 298 | count++ 299 | return n.get() * n.get() 300 | } 301 | 302 | @computed 303 | get num2(): number { 304 | return this.num 305 | } 306 | } 307 | 308 | test('init computed', () => { 309 | const a = new A() 310 | 311 | let result = 0 312 | 313 | const d = autorun(() => { 314 | result = a.num2 315 | }) 316 | 317 | expect(count).toEqual(1) 318 | n.set(n.get() + 1) 319 | expect(count).toEqual(2) 320 | 321 | d() 322 | }) 323 | 324 | test('out of scope', () => { 325 | const before = count 326 | 327 | n.set(n.get() + 1) 328 | n.set(n.get() + 1) 329 | n.set(n.get() + 1) 330 | n.set(n.get() + 1) 331 | 332 | expect(count).toEqual(before) 333 | }) 334 | }) 335 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------