aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoris2023-02-07 18:38:44 +0100
committerJoris2023-02-07 18:38:44 +0100
commitd0431c7f81e20dfb77a6fe154292d6b06f433984 (patch)
tree51689a11757be4eeb7f131e6dd78f4f71f4c006f
parent76268b9f5a1b7fcb43c614387ee78106abfc6fb2 (diff)
downloadrx-d0431c7f81e20dfb77a6fe154292d6b06f433984.tar.gz
rx-d0431c7f81e20dfb77a6fe154292d6b06f433984.tar.bz2
rx-d0431c7f81e20dfb77a6fe154292d6b06f433984.zip
Init version
-rw-r--r--README.md85
-rwxr-xr-xbin/dev-server16
-rw-r--r--flake.lock42
-rw-r--r--flake.nix21
-rw-r--r--public/index.html6
-rw-r--r--src/example.ts171
-rw-r--r--src/rx.ts398
-rw-r--r--tsconfig.json13
8 files changed, 752 insertions, 0 deletions
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 @@
+<!doctype html>
+<html lang="fr">
+<meta charset="utf-8">
+<title>Rx</title>
+<body></body>
+<script src="example.js"></script>
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<Array<number>>([], (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<string>
+ isChecked: RxAble<boolean>
+ 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<number>
+ 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<Child>
+ onmount?: (element: Element) => void
+ onunmount?: (element: Element) => void
+}
+
+export interface WithVar<A> extends Html {
+ type: 'WithVar'
+ init: A
+ getChildren: (v: Var<A>, update: (f: (value: A) => A) => void) => Child
+}
+
+interface Attributes {
+ [key: string]: Rx<AttributeValue> | AttributeValue
+}
+
+export type AttributeValue
+ = string
+ | number
+ | boolean
+ | ((event: Event) => void)
+ | ((element: Element) => void)
+
+export type Child = false | undefined | string | number | Html | Rx<Child> | Array<Child>
+
+function isChild(x: any): x is Child {
+ return (typeof x === 'string'
+ || typeof x === 'number'
+ || isHtml(x)
+ || isRx(x)
+ || Array.isArray(x))
+}
+
+type ValueOrArray<T> = T | Array<ValueOrArray<T>>
+
+export function h(
+ tagName: string,
+ x?: Attributes | Child,
+ ...children: Array<Child>
+): 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<A>(init: A, getChildren: (v: Var<A>, update: (f: (value: A) => A) => void) => Child): WithVar<A> {
+ return {
+ type: 'WithVar',
+ init,
+ getChildren
+ }
+}
+
+// Rx
+
+export type RxAble<A> = Rx<A> | A
+
+export class Rx<A> {
+ map<B>(f: (value: A) => B): Rx<B> {
+ return new Map<A, B>(this, f)
+ }
+
+ flatMap<B>(f: (value: A) => Rx<B>): Rx<B> {
+ return new FlatMap<A, B>(this, f)
+ }
+}
+
+class Var<A> extends Rx<A> {
+ readonly type: 'Var'
+ readonly id: string
+
+ constructor(id: string) {
+ super()
+ this.id = id
+ this.type = 'Var'
+ }
+}
+
+export class Map<A, B> extends Rx<B> {
+ readonly type: 'Map'
+ readonly rx: Rx<A>
+ readonly f: (value: A) => B
+
+ constructor(rx: Rx<A>, f: (value: A) => B) {
+ super()
+ this.type = 'Map'
+ this.rx = rx
+ this.f = f
+ }
+}
+
+export class FlatMap<A, B> extends Rx<B> {
+ readonly type: 'FlatMap'
+ readonly rx: Rx<A>
+ readonly f: (value: A) => Rx<B>
+
+ constructor(rx: Rx<A>, f: (value: A) => Rx<B>) {
+ 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<A> {
+ value: A
+ subscribers: Array<(value: A) => void>
+}
+
+class State {
+ readonly state: {[key: string]: StateEntry<any>}
+ varCounter: bigint
+
+ constructor() {
+ this.state = {}
+ this.varCounter = BigInt(0)
+ }
+
+ register<A>(initValue: A) {
+ const v = new Var(this.varCounter.toString())
+ this.varCounter += BigInt(1)
+ this.state[v.id] = {
+ value: initValue,
+ subscribers: []
+ }
+ return v
+ }
+
+ unregister<A>(v: Var<A>) {
+ delete this.state[v.id]
+ }
+
+ get<A>(v: Var<A>) {
+ return this.state[v.id].value
+ }
+
+ update<A>(v: Var<A>, 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<A>(v: Var<A>, 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<A>(state: State, rx: Rx<A>, effect: (value: A) => void): Cancelable {
+ if (isVar(rx)) {
+ const cancel = state.subscribe(rx, effect)
+ effect(state.get(rx))
+ return cancel
+ } else if (isMap<A, any>(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<A>(x: any): x is Rx<A> {
+ return x !== undefined && x.type !== undefined && (x.type === "Var" || x.type === "Map" || x.type === "FlatMap")
+}
+
+function isVar<A>(x: any): x is Var<A> {
+ return x.type === "Var"
+}
+
+function isMap<A, B>(x: any): x is Map<A, B> {
+ return x.type === "Map"
+}
+
+function isFlatMap<A, B>(x: any): x is FlatMap<A, B> {
+ 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<Cancelable> = []
+ let removes: Array<Removable> = []
+ 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<AttributeValue>(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<A>(x: any): x is Html {
+ return isTag<A>(x) || isWithVar<A>(x)
+}
+
+function isTag<A>(x: any): x is Tag {
+ return x !== undefined && x.type === "Tag"
+}
+
+function isWithVar<A>(x: any): x is WithVar<A> {
+ 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/**/*"]
+}