From d0431c7f81e20dfb77a6fe154292d6b06f433984 Mon Sep 17 00:00:00 2001 From: Joris Date: Tue, 7 Feb 2023 18:38:44 +0100 Subject: Init version --- README.md | 85 ++++++++++++ bin/dev-server | 16 +++ flake.lock | 42 ++++++ flake.nix | 21 +++ public/index.html | 6 + src/example.ts | 171 +++++++++++++++++++++++ src/rx.ts | 398 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 13 ++ 8 files changed, 752 insertions(+) create mode 100644 README.md create mode 100755 bin/dev-server create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 public/index.html create mode 100644 src/example.ts create mode 100644 src/rx.ts create mode 100644 tsconfig.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..f878b62 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Rx + +3.4kb unzipped DOM binding library which: + +- composes updatable values, `Rx`, with `map` and `flatMap`, +- re-render DOM portions under `Rx` values. + +Limitations: + +- whenever a DOM portion relies on a `Rx`, and the `Rx` value is updated, the + complete portions is removed and re-created, no matter if the DOM portion is + almost equal to the portion it replaced, +- no serious performance tests, I only noticed that it was faster than + `monadic-html` to create and update lots of rows, and slower than + Vanilla JS. + +## Usage + +### Hello world + +```typescript +import { h, mount } from 'rx' + +mount(h('div', 'Hello World!')) +``` + +### Attributes + +```typescript +import { h, mount } from 'rx' + +mount(h('button', + { className: 'g-Button', + color: 'green', + onclick: () => console.log('Clicked!') + }, + 'Click!')) +``` + +### State + +Counter with `-` and `+` buttons: + +```typescript +import { h, withVar, mount } from 'rx' + +mount( + withVar(0, (value, update) => [ + value, + h('button', { onclick: () => update(n => n - 1) }, '-'), + h('button', { onclick: () => update(n => n + 1) }, '+')])) +``` + +### Subscriptions + +Chronometer updating every second: + +```typescript +import { h, withVar, mount } from 'rx' + +mount( + withVar(0, (value, update) => { + const interval = window.setInterval(() => update(n => n + 1), 1000) + return h('div', + { onunmount: () => clearInterval(interval) }, + value + ) + })) +``` + +`onunmount` is a special attribute to keep track of the component lifecycle. +`onmount` is available as well. Those functions can take the `element` as an +argument. + +## Ideas + +- API: + - Rx debounce, filter, + - Routeur with sub-page support. +- Optimization: store intermediate rx instead of recomputing each time for multiple subscribers? + +## Inspiration + +- https://github.com/OlivierBlanvillain/monadic-html +- https://www.solidjs.com diff --git a/bin/dev-server b/bin/dev-server new file mode 100755 index 0000000..82655d1 --- /dev/null +++ b/bin/dev-server @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run server + +python -m http.server --directory public 8000 & +trap "fuser -k 8000/tcp" EXIT + +# Watch TypeScript + +CHECK="echo Checking TypeScript… && tsc --checkJs" +BUILD="esbuild --bundle src/example.ts --target=es2017 --outdir=public" +watchexec \ + --clear \ + --watch src \ + -- "$CHECK && $BUILD" diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..9f5bc7d --- /dev/null +++ b/flake.lock @@ -0,0 +1,42 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1667395993, + "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1675024979, + "narHash": "sha256-XGNts6Ku8DipMSI3o1sXPunXNewKHfzh21CqeYPXzog=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "87c5f6725442ab846b8a6719e63a0f079627c884", + "type": "github" + }, + "original": { + "owner": "nixos", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..325ed60 --- /dev/null +++ b/flake.nix @@ -0,0 +1,21 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem + (system: + let pkgs = nixpkgs.legacyPackages.${system}; + in { devShell = pkgs.mkShell { + buildInputs = with pkgs; [ + nodePackages.typescript + python3 + psmisc # fuser + esbuild + watchexec + ]; + }; } + ); +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..2cb9f22 --- /dev/null +++ b/public/index.html @@ -0,0 +1,6 @@ + + + +Rx + + diff --git a/src/example.ts b/src/example.ts new file mode 100644 index 0000000..c23a4eb --- /dev/null +++ b/src/example.ts @@ -0,0 +1,171 @@ +import { h, withVar, mount, RxAble } from 'rx' + +const imbricatedMaps = + withVar(1, (counter, updateCounter) => [ + counterComponent({ + value: counter, + onSub: () => updateCounter(n => n - 1), + onAdd: () => updateCounter(n => n + 1) + }), + counter.map(c1 => { + console.log('c1') + return [ + h('div', 'Inside first count'), + counter.map(c2 => { + console.log('c2') + return h('div', `Inside second count ${c2}`) + }) + ] + }) + ]) + +const flatMap = + withVar(1, (counter, updateCounter) => [ + counterComponent({ + value: counter, + onSub: () => updateCounter(n => n - 1), + onAdd: () => updateCounter(n => n + 1) + }), + counter.flatMap(c1 => { + console.log('c1') + return counter.map(c2 => { + console.log('c2') + return h('div', `Inside second count ${c2}`) + }) + }) + ]) + +const checkbox = + withVar(false, (checked, update) => [ + checkboxComponent({ + label: 'Checkbox', + isChecked: checked, + onCheck: (isChecked: boolean) => update(_ => isChecked), + }), + checked.map(isChecked => isChecked ? 'C’est coché!' : 'Ça n’est pas coché') + ]) + +const rxChildren = + withVar(3, (count, update) => [ + h('input', { + type: 'number', + value: count, + onchange: (event: Event) => update(_ => parseInt((event.target as HTMLInputElement).value)) + }), + h('div', 'FOO'), + count.map(n => Array(n).fill(null).map((_, i) => h('div', `A ${i}!`))), + h('div', 'BAR'), + count.map(n => Array(n).fill(null).map((_, i) => h('div', `B ${i}!`))), + h('div', 'BAZ') + ]) + +const nodesCancel = + withVar(false, (checked, update) => [ + checkboxComponent({ + label: 'Checkbox', + isChecked: checked, + onCheck: (isChecked: boolean) => update(_ => isChecked), + }), + checked.map(isChecked => isChecked ? rxChildren : undefined) + ]) + +const counters = + withVar>([], (counters, update) => { + return [ + counterComponent({ + value: counters.map(cs => cs.length), + onSub: () => update(cs => cs.slice(0, -1)), + onAdd: () => update(cs => cs.concat(0)) + }), + h('hr'), + h('div', { style: 'margin-top: 1rem' }, counters.map(cs => + cs.map((c, i) => + counterComponent({ + value: c, + onSub: () => update(cs => { + cs[i] = c - 1 + return cs + }), + onAdd: () => update(cs => { + cs[i] = c + 1 + return cs + }) + }) + ) + )) + ] + }) + +const rows = + withVar(1, (count, updateCount) => [ + h('input', { + type: 'number', + value: count, + onchange: (event: Event) => updateCount(_ => parseInt((event.target as HTMLInputElement).value)) + }), + count.map(n => Array(n).fill(null).map((_, i) => h('div', i))) + ]) + +const chrono = + withVar(false, (isChecked, updateCheck) => [ + checkboxComponent({ + label: 'Show counter', + isChecked, + onCheck: b => updateCheck(_ => b) + }), + isChecked.map(b => b && withVar(0, (elapsed, updateElapsed) => { + const interval = window.setInterval( + () => updateElapsed(n => n + 1), + 1000 + ) + return h( + 'div', + { onunmount: () => clearInterval(interval) }, + elapsed + ) + })) + ]) + +const view = h('main', + h('h1', 'Rx'), + chrono +) + +mount(view) + +// Checkbox + +interface CheckboxParams { + label: RxAble + isChecked: RxAble + onCheck: (isChecked: boolean) => void +} + +function checkboxComponent({ label, isChecked, onCheck }: CheckboxParams) { + return h('label', + h('input', + { type: 'checkbox', + onchange: (event: Event) => onCheck((event.target as HTMLInputElement).checked), + checked: isChecked, + } + ), + label + ) +} + +// Counter + +interface CounterParams { + value: RxAble + onAdd: () => void + onSub: () => void +} + +function counterComponent({ value, onAdd, onSub}: CounterParams) { + return h('div', + [ h('span', { style: 'margin-right: 5px' }, value), + h('button', { onclick: () => onSub() }, '-'), + h('button', { onclick: () => onAdd() }, '+'), + ] + ) +} diff --git a/src/rx.ts b/src/rx.ts new file mode 100644 index 0000000..dbdd3ad --- /dev/null +++ b/src/rx.ts @@ -0,0 +1,398 @@ +// Html + +export interface Html { + type: 'Tag' | 'WithVar' +} + +export interface Tag extends Html { + type: 'Tag' + tagName: string + attributes: Attributes + children?: Array + onmount?: (element: Element) => void + onunmount?: (element: Element) => void +} + +export interface WithVar extends Html { + type: 'WithVar' + init: A + getChildren: (v: Var, update: (f: (value: A) => A) => void) => Child +} + +interface Attributes { + [key: string]: Rx | AttributeValue +} + +export type AttributeValue + = string + | number + | boolean + | ((event: Event) => void) + | ((element: Element) => void) + +export type Child = false | undefined | string | number | Html | Rx | Array + +function isChild(x: any): x is Child { + return (typeof x === 'string' + || typeof x === 'number' + || isHtml(x) + || isRx(x) + || Array.isArray(x)) +} + +type ValueOrArray = T | Array> + +export function h( + tagName: string, + x?: Attributes | Child, + ...children: Array +): Tag { + if (x === undefined || x === false) { + return { + type: 'Tag', + tagName, + attributes: {} + } + } else if (isChild(x)) { + return { + type: 'Tag', + tagName, + attributes: {}, + children: [x, ...children], + } + } else { + let attributes = x as Attributes + let onmount, onunmount + if ('onmount' in attributes) { + onmount = attributes['onmount'] as (element: Element) => void + delete attributes['onmount'] + } + if ('onunmount' in attributes) { + onunmount = attributes['onunmount'] as (element: Element) => void + delete attributes['onunmount'] + } + return { + type: 'Tag', + tagName, + attributes, + children, + onmount, + onunmount + } + } +} + +export function withVar(init: A, getChildren: (v: Var, update: (f: (value: A) => A) => void) => Child): WithVar { + return { + type: 'WithVar', + init, + getChildren + } +} + +// Rx + +export type RxAble = Rx | A + +export class Rx { + map(f: (value: A) => B): Rx { + return new Map(this, f) + } + + flatMap(f: (value: A) => Rx): Rx { + return new FlatMap(this, f) + } +} + +class Var extends Rx { + readonly type: 'Var' + readonly id: string + + constructor(id: string) { + super() + this.id = id + this.type = 'Var' + } +} + +export class Map extends Rx { + readonly type: 'Map' + readonly rx: Rx + readonly f: (value: A) => B + + constructor(rx: Rx, f: (value: A) => B) { + super() + this.type = 'Map' + this.rx = rx + this.f = f + } +} + +export class FlatMap extends Rx { + readonly type: 'FlatMap' + readonly rx: Rx + readonly f: (value: A) => Rx + + constructor(rx: Rx, f: (value: A) => Rx) { + super() + this.type = 'FlatMap' + this.rx = rx + this.f = f + } +} + +// Mount + +export function mount(child: Child): Cancelable { + const state = new State() + let appendRes = appendChild(state, document.body, child) + return appendRes.cancel +} + +interface StateEntry { + value: A + subscribers: Array<(value: A) => void> +} + +class State { + readonly state: {[key: string]: StateEntry} + varCounter: bigint + + constructor() { + this.state = {} + this.varCounter = BigInt(0) + } + + register(initValue: A) { + const v = new Var(this.varCounter.toString()) + this.varCounter += BigInt(1) + this.state[v.id] = { + value: initValue, + subscribers: [] + } + return v + } + + unregister(v: Var) { + delete this.state[v.id] + } + + get(v: Var) { + return this.state[v.id].value + } + + update(v: Var, f: (value: A) => A) { + const value = f(this.state[v.id].value) + this.state[v.id].value = value + this.state[v.id].subscribers.forEach(notify => { + // Don’t notify if it has been removed from a precedent notifier + if (this.state[v.id].subscribers.indexOf(notify) !== -1) { + notify(value) + } + }) + } + + subscribe(v: Var, notify: (value: A) => void): Cancelable { + this.state[v.id].subscribers.push(notify) + return () => this.state[v.id].subscribers = this.state[v.id].subscribers.filter(n => n !== notify) + } +} + +// Cancelable + +type Cancelable = () => void + +const voidCancel = () => {} + +// Removable + +type Removable = () => void + +const voidRemove = () => {} + +// Rx run + +function rxRun(state: State, rx: Rx, effect: (value: A) => void): Cancelable { + if (isVar(rx)) { + const cancel = state.subscribe(rx, effect) + effect(state.get(rx)) + return cancel + } else if (isMap(rx)) { + return rxRun(state, rx.rx, value => effect(rx.f(value))) + } else if (isFlatMap(rx)) { + let cancel1 = voidCancel + const cancel2 = rxRun(state, rx.rx, (value: A) => { + cancel1() + cancel1 = rxRun(state, rx.f(value), effect) + }) + return () => { + cancel2() + cancel1() + } + } else { + throw new Error(`Unrecognized rx: ${rx}`) + } +} + +function isRx(x: any): x is Rx { + return x !== undefined && x.type !== undefined && (x.type === "Var" || x.type === "Map" || x.type === "FlatMap") +} + +function isVar(x: any): x is Var { + return x.type === "Var" +} + +function isMap(x: any): x is Map { + return x.type === "Map" +} + +function isFlatMap(x: any): x is FlatMap { + return x.type === "FlatMap" +} + +// Append + +interface AppendResult { + cancel: Cancelable + remove: Removable + lastAdded?: Node +} + +function appendChild(state: State, element: Element, child: Child, lastAdded?: Node): AppendResult { + if (Array.isArray(child)) { + let cancels: Array = [] + let removes: Array = [] + child.forEach((o) => { + const appendResult = appendChild(state, element, o, lastAdded) + cancels.push(appendResult.cancel) + removes.push(appendResult.remove) + lastAdded = appendResult.lastAdded + }) + return { + cancel: () => cancels.forEach((o) => o()), + remove: () => removes.forEach((o) => o()), + lastAdded + } + } else if (typeof child == "string") { + const node = document.createTextNode(child) + appendNode(element, node, lastAdded) + return { + cancel: voidCancel, + remove: () => element.removeChild(node), + lastAdded: node + } + } else if (typeof child == "number") { + return appendChild(state, element, child.toString(), lastAdded) + } else if (isTag(child)) { + const { tagName, attributes, children, onmount, onunmount } = child + + const childElement = document.createElement(tagName) + const cancelAttributes = Object.entries(attributes).map(([key, value]) => { + if (isRx(value)) { + return rxRun(state, value, newValue => setAttribute(state, childElement, key, newValue)) + } else { + setAttribute(state, childElement, key, value) + } + }) + + const appendChildrenRes = appendChild(state, childElement, children) + + appendNode(element, childElement, lastAdded) + + if (onmount !== undefined) { + onmount(childElement) + } + + return { + cancel: () => { + cancelAttributes.forEach(cancel => cancel !== undefined ? cancel() : {}) + appendChildrenRes.cancel() + if (onunmount !== undefined) { + onunmount(childElement) + } + }, + remove: () => element.removeChild(childElement), + lastAdded: childElement, + } + } else if (isWithVar(child)) { + const { init, getChildren } = child + const v = state.register(init) + const children = getChildren(v, f => state.update(v, f)) + const appendRes = appendChild(state, element, children) + return { + cancel: () => { + appendRes.cancel() + state.unregister(v) + }, + remove: () => appendRes.remove(), + lastAdded: appendRes.lastAdded + } + } else if (isRx(child)) { + const rxBase = document.createTextNode("") + element.append(rxBase) + let appendRes: AppendResult = { + cancel: voidCancel, + remove: voidRemove, + lastAdded: rxBase + } + const cancelRx = rxRun(state, child, (value: Child) => { + appendRes.cancel() + appendRes.remove() + appendRes = appendChild(state, element, value, rxBase) + }) + return { + cancel: () => { + appendRes.cancel() + cancelRx() + }, + remove: appendRes.remove, + lastAdded: appendRes.lastAdded, + } + } else if (child === undefined || child === false) { + return { + cancel: voidCancel, + remove: voidRemove, + lastAdded + } + } else { + throw new Error(`Unrecognized child: ${child}`) + } +} + +function isHtml(x: any): x is Html { + return isTag(x) || isWithVar(x) +} + +function isTag(x: any): x is Tag { + return x !== undefined && x.type === "Tag" +} + +function isWithVar(x: any): x is WithVar { + return x !== undefined && x.type === "WithVar" +} + +function appendNode(base: Element, node: Node, lastAdded?: Node) { + if (lastAdded !== undefined) { + base.insertBefore(node, lastAdded.nextSibling) + } else { + base.append(node) + } +} + +function setAttribute(state: State, element: Element, key: string, attribute: AttributeValue) { + if (typeof attribute == "boolean") { + if (attribute) { + // @ts-ignore + element[key] = "true" + } + } else if (typeof attribute == "number") { + // @ts-ignore + element[key] = attribute.toString() + } else if (typeof attribute == "string") { + // @ts-ignore + element[key] = attribute + } else { + // @ts-ignore + element[key] = (event: Event) => attribute(event) + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6c9e683 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "amd", + "target": "es2020", + "baseUrl": "src", + "outFile": "public/main.js", + "noImplicitAny": true, + "strictNullChecks": true, + "removeComments": true, + "preserveConstEnums": true + }, + "include": ["src/**/*"] +} -- cgit v1.2.3