From 9a5a144625f4342876ddb6330e9cb08c12cf299d Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 30 May 2024 17:18:14 -0700 Subject: [PATCH 01/30] WIP --- .../2024-05-30-common-frp/package-lock.json | 29 ++ sketches/2024-05-30-common-frp/package.json | 17 + .../2024-05-30-common-frp/src/common-frp.ts | 308 ++++++++++++++++++ sketches/2024-05-30-common-frp/tsconfig.json | 27 ++ 4 files changed, 381 insertions(+) create mode 100644 sketches/2024-05-30-common-frp/package-lock.json create mode 100644 sketches/2024-05-30-common-frp/package.json create mode 100644 sketches/2024-05-30-common-frp/src/common-frp.ts create mode 100644 sketches/2024-05-30-common-frp/tsconfig.json diff --git a/sketches/2024-05-30-common-frp/package-lock.json b/sketches/2024-05-30-common-frp/package-lock.json new file mode 100644 index 000000000..7a8b256e6 --- /dev/null +++ b/sketches/2024-05-30-common-frp/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "common-frp", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "common-frp", + "version": "0.0.1", + "license": "MIT", + "devDependencies": { + "typescript": "^5.4.5" + } + }, + "node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/sketches/2024-05-30-common-frp/package.json b/sketches/2024-05-30-common-frp/package.json new file mode 100644 index 000000000..84a32a4b3 --- /dev/null +++ b/sketches/2024-05-30-common-frp/package.json @@ -0,0 +1,17 @@ +{ + "name": "common-frp", + "version": "0.0.1", + "description": "Common functional reactive programming utilities", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "frp" + ], + "author": "Gordon Brander", + "license": "MIT", + "devDependencies": { + "typescript": "^5.4.5" + } +} diff --git a/sketches/2024-05-30-common-frp/src/common-frp.ts b/sketches/2024-05-30-common-frp/src/common-frp.ts new file mode 100644 index 000000000..30b6ade09 --- /dev/null +++ b/sketches/2024-05-30-common-frp/src/common-frp.ts @@ -0,0 +1,308 @@ +export const config = { + debug: false +} + +const debugLog = (msg: string) => { + if (config.debug) { + console.debug(msg) + } +} + +/** + * Pipe a value through a series of functions + * @param value - The value to pipe + * @param funcs - The functions to pipe the value through + * @returns The final value after being piped through all functions + */ +export const pipe = ( + value: any, + ...funcs: Array<(value: any) => any> +) => funcs.reduce((acc, func) => func(acc), value) + +export const chooseLeft = (left: T, right: T) => left + +const TransactionQueue = (name: string) => { + const queue = new Map<(value: any) => void, any>() + + const transact = () => { + debugLog(`TransactionQueue ${name}: transacting ${queue.size} jobs`) + for (const [perform, value] of queue) { + perform(value) + } + queue.clear() + } + + /** + * Queue a job to perform during the next transaction, + * along with a value to pass to the job + * @param perform - The function to perform (used as the unique key in + * the queue) + * @param value - The value to pass to the perform function + * @param choose - A function to choose between two values if more than one + * has been set during the transaction (default is chooseLeft) + */ + const withTransaction = ( + perform: (value: T) => void, + value: T, + choose: (left: T, right: T) => T = chooseLeft + ) => { + const left = queue.get(perform) + queue.set(perform, left !== undefined ? choose(left, value) : value) + } + + return {transact, withTransaction} +} + +const TransactionManager = () => { + const streamQueue = TransactionQueue('streams') + const cellQueue = TransactionQueue('cells') + const computedQueue = TransactionQueue('computed') + + let isScheduled = false + + const schedule = () => { + if (isScheduled) { + return + } + debugLog(`TransactionManager: transaction scheduled`) + isScheduled = true + queueMicrotask(transact) + } + + const transact = () => { + debugLog(`TransactionManager: transaction start`) + debugLog(`TransactionManager: transact events`) + // First perform all events + streamQueue.transact() + debugLog(`TransactionManager: transact cells`) + // Then perform all cell updates + cellQueue.transact() + + debugLog(`TransactionManager: transact computed`) + // Finally, update computed cells + computedQueue.transact() + isScheduled = false + debugLog(`TransactionManager: transaction end`) + } + + const withCells = ( + perform: (value: T) => void, + value: T, + choose: (left: T, right: T) => T = chooseLeft + ) => { + cellQueue.withTransaction(perform, value, choose) + schedule() + } + + const withStreams = ( + perform: (value: T) => void, + value: T, + choose: (left: T, right: T) => T = chooseLeft + ) => { + streamQueue.withTransaction(perform, value, choose) + schedule() + } + + const withComputed = ( + perform: (value: T) => void, + value: T, + choose: (left: T, right: T) => T = chooseLeft + ) => { + computedQueue.withTransaction(perform, value, choose) + schedule() + } + + return {withCells, withStreams, withComputed} +} + +const {withCells, withStreams, withComputed} = TransactionManager() + +type Topic = { + notify: (value: T) => void, + sink: (subscriber: (value: T) => void) => () => void +} + +/** + * Create one-to-many event broadcast channel for a list of subscribers. + * Publishes synchronously to all subscribers. + */ +const Topic = (): Topic => { + const subscribers = new Set<(value: T) => void>() + + const notify = (value: T) => { + for (const subscriber of subscribers) { + subscriber(value) + } + } + + const sink = (subscriber: (value: T) => void) => { + subscribers.add(subscriber) + return () => subscribers.delete(subscriber) + } + + return {notify, sink} +} + +export type Send = (value: T) => void + +type Streamable = { + sink: (subscriber: (value: T) => void) => () => void +} + +export class Stream implements Streamable { + #topic: Topic + #choose: (left: T, right: T) => T + + constructor( + choose: (left: T, right: T) => T = chooseLeft + ) { + this.#topic = Topic() + this.#choose = choose + } + + send(value: T) { + withStreams(this.#topic.notify, value, this.#choose) + } + + sink(subscriber: (value: T) => void) { + return this.#topic.sink(subscriber) + } +} + +export class ReadOnlyStream implements Streamable { + #stream: Streamable + + constructor(stream: Stream) { + this.#stream = stream + } + + sink(subscriber: (value: T) => void) { + return this.#stream.sink(subscriber) + } +} + +export const useStream = ( + generate: (send: (value: T) => void) => void, + choose: (left: T, right: T) => T = chooseLeft +): Stream => { + const {notify, sink} = Topic() + + const send = (value: T) => { + withStreams(notify, value, choose) + } + + generate(send) + + return {sink} +} + +export const mapStream = ( + stream: Stream, + map: (value: T) => U +) => useStream(send => { + stream.sink(value => send(map(value))) +}) + +export const scanStream = ( + stream: Stream, + step: (state: U, value: T) => U, + initial: U +) => { + let state = initial + return useStream(send => { + stream.sink(value => { + state = step(state, value) + send(state) + }) + }) +} + +export const mergeStreams = ( + left: Stream, + right: Stream, + choose: (left: T, right: T) => T = chooseLeft +) => useStream(send => { + left.sink(value => send(value)) + right.sink(value => send(value)) +}) + +/** + * "Hold" the latest value from a stream in a cell + * @param stream - the stream to update cell + * @param initial - the initial value for the cell + * @returns cell + */ +export const hold = (stream: Stream, initial: T): Cell => { + const [cell, send] = useCell(initial) + stream.sink(send) + return cell +} + + +const isEqual = Object.is + +export type Cell = { + get: () => T, + sink: (subscriber: (value: T) => void) => () => void +} + +export const useCell = (value: T): [Cell, Send] => { + const {notify, sink} = Topic() + let state = value + + const setState = (value: T) => { + // Only notify downstream if state has changed + if (!isEqual(state, value)) { + state = value + notify(value) + } + } + + const get = () => state + + const send = (value: T) => { + withCells(notify, value) + } + + return [{get, sink}, send] +} + +export const useComputed = ( + upstream: Array>, + calc: () => T +): Cell => { + const {notify, sink} = Topic() + let state = calc() + let isDirty = false + + const setState = (value: T) => { + // Only notify downstream if state has changed + if (!isEqual(state, value)) { + state = value + notify(value) + } + } + + const get = () => { + if (isDirty) { + state = calc() + isDirty = false + } + return state + } + + const markDirty = () => { + isDirty = true + withComputed(notify, calc()) + } + + for (const cell of upstream) { + cell.sink(markDirty) + } + + const send = (value: T) => { + withComputed(notify, value) + } + + return {get, sink} +} \ No newline at end of file diff --git a/sketches/2024-05-30-common-frp/tsconfig.json b/sketches/2024-05-30-common-frp/tsconfig.json new file mode 100644 index 000000000..6cd254116 --- /dev/null +++ b/sketches/2024-05-30-common-frp/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "es2015", + "moduleResolution": "node", + "lib": ["esnext.array", "esnext", "es2017", "dom"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "inlineSources": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitThis": true, + "outDir": "./", + // Only necessary because @types/uglify-js can't find types for source-map + "skipLibCheck": true, + "experimentalDecorators": true + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [] + } \ No newline at end of file From 3d58db205f761647d10efb49f8cbabc1427c1ac3 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Fri, 31 May 2024 11:01:30 -0700 Subject: [PATCH 02/30] More progress toward cell and stream types - Convert to class style - Four types: stream, readonly stream, cell, computedcell --- .../2024-05-30-common-frp/src/common-frp.ts | 232 +++++++++--------- 1 file changed, 115 insertions(+), 117 deletions(-) diff --git a/sketches/2024-05-30-common-frp/src/common-frp.ts b/sketches/2024-05-30-common-frp/src/common-frp.ts index 30b6ade09..1758ee3ad 100644 --- a/sketches/2024-05-30-common-frp/src/common-frp.ts +++ b/sketches/2024-05-30-common-frp/src/common-frp.ts @@ -8,28 +8,22 @@ const debugLog = (msg: string) => { } } -/** - * Pipe a value through a series of functions - * @param value - The value to pipe - * @param funcs - The functions to pipe the value through - * @returns The final value after being piped through all functions - */ -export const pipe = ( - value: any, - ...funcs: Array<(value: any) => any> -) => funcs.reduce((acc, func) => func(acc), value) +export const chooseLeft = (left: T, _right: T) => left -export const chooseLeft = (left: T, right: T) => left +class TransactionQueue { + name: string + #queue = new Map<(value: any) => void, any>() -const TransactionQueue = (name: string) => { - const queue = new Map<(value: any) => void, any>() + constructor(name: string) { + this.name = name + } - const transact = () => { - debugLog(`TransactionQueue ${name}: transacting ${queue.size} jobs`) - for (const [perform, value] of queue) { + transact() { + debugLog(`TransactionQueue ${this.name}: transacting ${this.#queue.size} jobs`) + for (const [perform, value] of this.#queue) { perform(value) } - queue.clear() + this.#queue.clear() } /** @@ -41,22 +35,20 @@ const TransactionQueue = (name: string) => { * @param choose - A function to choose between two values if more than one * has been set during the transaction (default is chooseLeft) */ - const withTransaction = ( + withTransaction = ( perform: (value: T) => void, value: T, choose: (left: T, right: T) => T = chooseLeft ) => { - const left = queue.get(perform) - queue.set(perform, left !== undefined ? choose(left, value) : value) + const left = this.#queue.get(perform) + this.#queue.set(perform, left !== undefined ? choose(left, value) : value) } - - return {transact, withTransaction} } const TransactionManager = () => { - const streamQueue = TransactionQueue('streams') - const cellQueue = TransactionQueue('cells') - const computedQueue = TransactionQueue('computed') + const streamQueue = new TransactionQueue('streams') + const cellQueue = new TransactionQueue('cells') + const computedQueue = new TransactionQueue('computed') let isScheduled = false @@ -117,9 +109,12 @@ const TransactionManager = () => { const {withCells, withStreams, withComputed} = TransactionManager() +export type Send = (value: T) => void +export type Cancel = () => void + type Topic = { notify: (value: T) => void, - sink: (subscriber: (value: T) => void) => () => void + sink: (subscriber: Send) => Cancel } /** @@ -135,7 +130,7 @@ const Topic = (): Topic => { } } - const sink = (subscriber: (value: T) => void) => { + const sink = (subscriber: Send): Cancel => { subscribers.add(subscriber) return () => subscribers.delete(subscriber) } @@ -143,7 +138,11 @@ const Topic = (): Topic => { return {notify, sink} } -export type Send = (value: T) => void +const combineCancels = (cancels: Array): Cancel => () => { + for (const cancel of cancels) { + cancel() + } +} type Streamable = { sink: (subscriber: (value: T) => void) => () => void @@ -182,127 +181,126 @@ export class ReadOnlyStream implements Streamable { } export const useStream = ( - generate: (send: (value: T) => void) => void, - choose: (left: T, right: T) => T = chooseLeft -): Stream => { - const {notify, sink} = Topic() - - const send = (value: T) => { - withStreams(notify, value, choose) - } - - generate(send) - - return {sink} + produce: (send: Send) => void +) => { + const downstream = new Stream() + produce(value => downstream.send(value)) + return new ReadOnlyStream(downstream) } export const mapStream = ( - stream: Stream, - map: (value: T) => U + upstream: Stream, + transform: (value: T) => U ) => useStream(send => { - stream.sink(value => send(map(value))) + upstream.sink(value => send(transform(value))) }) export const scanStream = ( - stream: Stream, + upstream: Stream, step: (state: U, value: T) => U, initial: U -) => { +) => useStream(send => { let state = initial - return useStream(send => { - stream.sink(value => { - state = step(state, value) - send(state) - }) + upstream.sink(value => { + state = step(state, value) + send(state) }) -} - -export const mergeStreams = ( - left: Stream, - right: Stream, - choose: (left: T, right: T) => T = chooseLeft -) => useStream(send => { - left.sink(value => send(value)) - right.sink(value => send(value)) }) -/** - * "Hold" the latest value from a stream in a cell - * @param stream - the stream to update cell - * @param initial - the initial value for the cell - * @returns cell - */ -export const hold = (stream: Stream, initial: T): Cell => { - const [cell, send] = useCell(initial) - stream.sink(send) - return cell -} - - const isEqual = Object.is -export type Cell = { - get: () => T, +export type Cellable = { + readonly value: T, sink: (subscriber: (value: T) => void) => () => void } -export const useCell = (value: T): [Cell, Send] => { - const {notify, sink} = Topic() - let state = value +export class Cell implements Cellable { + #name: string + #value: T + #topic = Topic() - const setState = (value: T) => { - // Only notify downstream if state has changed - if (!isEqual(state, value)) { - state = value - notify(value) - } + constructor(value: T, name: string) { + this.#value = value + this.#name = name } - const get = () => state + get name() { + return this.#name + } - const send = (value: T) => { - withCells(notify, value) + get value() { + return this.#value } - return [{get, sink}, send] -} + #setState = (value: T) => { + // Only notify downstream if state has changed value + if (!isEqual(this.#value, value)) { + this.#value = value + this.#topic.notify(value) + } + } -export const useComputed = ( - upstream: Array>, - calc: () => T -): Cell => { - const {notify, sink} = Topic() - let state = calc() - let isDirty = false - - const setState = (value: T) => { - // Only notify downstream if state has changed - if (!isEqual(state, value)) { - state = value - notify(value) - } + send(value: T) { + withCells(this.#setState, value) } - const get = () => { - if (isDirty) { - state = calc() - isDirty = false - } - return state + sink(subscriber: Send) { + subscriber(this.#value) + return this.#topic.sink(subscriber) } +} - const markDirty = () => { - isDirty = true - withComputed(notify, calc()) +export const getCell = (cell: Cell) => cell.value + +export class ComputedCell implements Cellable { + #topic = Topic() + #isDirty = false + #value: T + #recalc: () => T + #upstreams: Array> + cancel: Cancel + + constructor( + upstreams: Array>, + calc: (...values: Array) => T + ) { + this.#upstreams = upstreams + this.#recalc = () => calc(...this.#upstreams.map(getCell)) + this.#value = this.#recalc() + + this.cancel = combineCancels( + upstreams.map(cell => cell.sink(value => { + withComputed(this.#markDirty, value) + })) + ) } - for (const cell of upstream) { - cell.sink(markDirty) + #markDirty = () => { + this.#isDirty = true } - const send = (value: T) => { - withComputed(notify, value) + get value() { + if (this.#isDirty) { + this.#value = this.#recalc() + this.#isDirty = false + } + return this.#value } - return {get, sink} -} \ No newline at end of file + sink(subscriber: Send): () => void { + return this.#topic.sink(subscriber) + } +} + +/** + * "Hold" the latest value from a stream in a cell + * @param stream - the stream to update cell + * @param initial - the initial value for the cell + * @returns cell + */ +export const hold = (stream: Stream, initial: T): ComputedCell => { + const cell = new Cell(initial, 'hold') + // TODO deal with cancel + stream.sink(value => cell.send(value)) + return new ComputedCell([cell], (value) => value) +} From 3d3f05332796eb1457b3b1eb8c2149797111a6b4 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Fri, 31 May 2024 16:56:58 -0700 Subject: [PATCH 03/30] Add notes on FRP libs and papers --- sketches/2024-05-30-common-frp/NOTES.md | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 sketches/2024-05-30-common-frp/NOTES.md diff --git a/sketches/2024-05-30-common-frp/NOTES.md b/sketches/2024-05-30-common-frp/NOTES.md new file mode 100644 index 000000000..355d44c0a --- /dev/null +++ b/sketches/2024-05-30-common-frp/NOTES.md @@ -0,0 +1,28 @@ +# Notes + +Rough notes and references while designing. + +## Prior art + +Classical FRP: + +- [Sodium FRP](https://github.com/SodiumFRP) + - Typescript https://github.com/SodiumFRP/sodium-typescript/tree/master/src/lib/sodium + +Signals: + +- https://github.com/tc39/proposal-signals +- https://www.solidjs.com/ +- https://preactjs.com/guide/v10/signals/ + +Observables: + +- https://rxjs.dev/ +- https://github.com/tc39/proposal-observable +- https://developer.apple.com/documentation/combine/ + +## Concepts + +- Blog: [The Evolution of Signals in Javascript](https://dev.to/this-is-learning/the-evolution-of-signals-in-javascript-8ob). Notes on low-level implementation of signal libraries, from the creator of SolidJS. +- Blog: [INtorduction to fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf) from the creator of SolidJS. +- Paper: [Push-pull FRP](http://conal.net/papers/push-pull-frp/push-pull-frp.pdf), Conal Elliott \ No newline at end of file From 40414f0b47e0f44c568ba6a2630e1a1b3aa87b6d Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Fri, 31 May 2024 17:10:51 -0700 Subject: [PATCH 04/30] More notes --- sketches/2024-05-30-common-frp/NOTES.md | 57 +++++++++++++++++++------ 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/sketches/2024-05-30-common-frp/NOTES.md b/sketches/2024-05-30-common-frp/NOTES.md index 355d44c0a..63fe5ec6a 100644 --- a/sketches/2024-05-30-common-frp/NOTES.md +++ b/sketches/2024-05-30-common-frp/NOTES.md @@ -4,25 +4,58 @@ Rough notes and references while designing. ## Prior art -Classical FRP: +### Classical FRP + +Qualities: + +- Behaviors and events (also called cells and streams, or signals and streams) + - Behaviors are reactive containers for state (think spreadsheet cell) + - Events are streams of events over time + - Both are synchronized by a transaction system that makes sure changes happen during discrete "moments" in time, and inconsistent graph states are not observable. +- Comes in discrete and continuous flavors. We only care about discrete for our purposes. +- Resulting computation graph is pure. +- Theory pioneered by Conal Elliott. +- Full Turing-complete theory of reactive computation. + +Libraires: - [Sodium FRP](https://github.com/SodiumFRP) - - Typescript https://github.com/SodiumFRP/sodium-typescript/tree/master/src/lib/sodium + - [Sodium Typescript](https://github.com/SodiumFRP/sodium-typescript/tree/master/src/lib/sodium) + +### Signals + +Qualities: + +- Combines event streams and behaviors into a single concept. +- Often uses single-transaction callback registration technique developed by S.js +- Often uses push-pull FRP +- Often uses closure and a "reactive scope" stack machine with a helper like `useEffect()` to register listeners + +Libraries: + +- [TC39 Signals Proposal](https://github.com/tc39/proposal-signals) +- [SolidJS](https://www.solidjs.com/) +- [Preact Signals](https://preactjs.com/guide/v10/signals/) +- [S.js](https://github.com/adamhaile/S) implements transactions and dynamic graph with single-transaction callback registration, eliminating listener memory leaks. + +### Observables -Signals: +Qualities: -- https://github.com/tc39/proposal-signals -- https://www.solidjs.com/ -- https://preactjs.com/guide/v10/signals/ +- No transaction system +- No state (streams only) +- Largely static graphs (1st-order FRP) with explicit subscription cancellation for dynamic graphs -Observables: +Libraries: -- https://rxjs.dev/ -- https://github.com/tc39/proposal-observable -- https://developer.apple.com/documentation/combine/ +- [RxJS](https://rxjs.dev/) +- [TC39 Observable Proposal](https://github.com/tc39/proposal-observable) +- [Apple Combine](https://developer.apple.com/documentation/combine/) ## Concepts +- Paper: [Push-pull FRP](http://conal.net/papers/push-pull-frp/push-pull-frp.pdf), Conal Elliott +- Book: [Functional Reactive Programming](https://www.manning.com/books/functional-reactive-programming), Manning 2016 +- Talk: [Controlling space and time: understanding the many formulations of FRP](https://www.youtube.com/watch?v=Agu6jipKfYw), Evan Czaplicki - Blog: [The Evolution of Signals in Javascript](https://dev.to/this-is-learning/the-evolution-of-signals-in-javascript-8ob). Notes on low-level implementation of signal libraries, from the creator of SolidJS. -- Blog: [INtorduction to fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf) from the creator of SolidJS. -- Paper: [Push-pull FRP](http://conal.net/papers/push-pull-frp/push-pull-frp.pdf), Conal Elliott \ No newline at end of file +- Blog: [Introduction to fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf) from the creator of SolidJS. From 5d8a6799e9be5e36391a724b310ca5b6895ba649 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Fri, 31 May 2024 17:17:03 -0700 Subject: [PATCH 05/30] More notes --- sketches/2024-05-30-common-frp/NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sketches/2024-05-30-common-frp/NOTES.md b/sketches/2024-05-30-common-frp/NOTES.md index 63fe5ec6a..310566d51 100644 --- a/sketches/2024-05-30-common-frp/NOTES.md +++ b/sketches/2024-05-30-common-frp/NOTES.md @@ -37,6 +37,7 @@ Libraries: - [SolidJS](https://www.solidjs.com/) - [Preact Signals](https://preactjs.com/guide/v10/signals/) - [S.js](https://github.com/adamhaile/S) implements transactions and dynamic graph with single-transaction callback registration, eliminating listener memory leaks. +- [Elm Signals 3.0.0](https://github.com/elm-lang/core/blob/3.0.0/src/Native/Signal.js). Deprecated, but the implementation can be found here. 1st order FRP. ### Observables @@ -59,3 +60,4 @@ Libraries: - Talk: [Controlling space and time: understanding the many formulations of FRP](https://www.youtube.com/watch?v=Agu6jipKfYw), Evan Czaplicki - Blog: [The Evolution of Signals in Javascript](https://dev.to/this-is-learning/the-evolution-of-signals-in-javascript-8ob). Notes on low-level implementation of signal libraries, from the creator of SolidJS. - Blog: [Introduction to fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf) from the creator of SolidJS. +- GitHub: [General Theory of Reactivity](https://github.com/kriskowal/gtor) From 2c9250855ac3cba6a686e0c47743a856f0ac8e51 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Sat, 1 Jun 2024 13:30:16 -0700 Subject: [PATCH 06/30] More notes --- sketches/2024-05-30-common-frp/NOTES.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/sketches/2024-05-30-common-frp/NOTES.md b/sketches/2024-05-30-common-frp/NOTES.md index 310566d51..cbf4a1857 100644 --- a/sketches/2024-05-30-common-frp/NOTES.md +++ b/sketches/2024-05-30-common-frp/NOTES.md @@ -61,3 +61,14 @@ Libraries: - Blog: [The Evolution of Signals in Javascript](https://dev.to/this-is-learning/the-evolution-of-signals-in-javascript-8ob). Notes on low-level implementation of signal libraries, from the creator of SolidJS. - Blog: [Introduction to fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf) from the creator of SolidJS. - GitHub: [General Theory of Reactivity](https://github.com/kriskowal/gtor) + +### Cold vs. Hot Observables + +The distinction between cold and hot observables (or their equivalent) is a significant part of many FRP systems. Cold observables are those where the data-producing sequence starts anew for each subscriber, whereas hot observables share a single execution path among all subscribers. This distinction affects how data streams are multicast to multiple observers. + +- Cold observable: creates a data producer for each subscriber. + - The observable is a pure transformation over the data producer. + - Examples: [Reducers in Clojure](https://clojure.org/reference/reducers), [RxJS Observables](https://rxjs.dev). +- Hot observable: Multicast. Maintains a list of subscribers and dispatches to them from a single data producing source. + - Examples: [share](https://rxjs.dev/api/index/function/share) in RxJS. + - Has to deal with callback cleanup bookkeeping in dynamic graphs. From 6880092b86db5b4d97adbb2da035caa3dec939f0 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Sat, 1 Jun 2024 22:38:51 -0700 Subject: [PATCH 07/30] Working on read-only signals and computed cells The functioanl style of definition is definitely terser and more fluent. Being able to destructure and restructure cells means we can offer readonly without a special type. --- sketches/2024-05-30-common-frp/README.md | 18 + sketches/2024-05-30-common-frp/index.html | 11 + .../2024-05-30-common-frp/package-lock.json | 788 +++++++++++++++++- sketches/2024-05-30-common-frp/package.json | 8 +- .../2024-05-30-common-frp/src/common-frp.ts | 272 +++--- .../src/example/basic.ts | 15 + 6 files changed, 959 insertions(+), 153 deletions(-) create mode 100644 sketches/2024-05-30-common-frp/README.md create mode 100644 sketches/2024-05-30-common-frp/index.html create mode 100644 sketches/2024-05-30-common-frp/src/example/basic.ts diff --git a/sketches/2024-05-30-common-frp/README.md b/sketches/2024-05-30-common-frp/README.md new file mode 100644 index 000000000..568d17894 --- /dev/null +++ b/sketches/2024-05-30-common-frp/README.md @@ -0,0 +1,18 @@ +# Common FRP + +Common functional reactive programming utilities. + +## Goals + +- [ ] Classical FRP with behaviors and events (also called cells and streams, or signals and streams) +- [ ] 1st order FRP, static graph +- [ ] Graph description can be serialized to JSON +- [ ] Glitch free: updates happen during discrete moments using a transaction system +- [ ] Cycles + +## Todo + +- [ ] Transactions +- [ ] Transformations should happen during same transaction +- [ ] Cell to stream happens one transaction later + diff --git a/sketches/2024-05-30-common-frp/index.html b/sketches/2024-05-30-common-frp/index.html new file mode 100644 index 000000000..c2377acd1 --- /dev/null +++ b/sketches/2024-05-30-common-frp/index.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/sketches/2024-05-30-common-frp/package-lock.json b/sketches/2024-05-30-common-frp/package-lock.json index 7a8b256e6..fa314be5b 100644 --- a/sketches/2024-05-30-common-frp/package-lock.json +++ b/sketches/2024-05-30-common-frp/package-lock.json @@ -9,7 +9,738 @@ "version": "0.0.1", "license": "MIT", "devDependencies": { - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "vite": "^5.2.12" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz", + "integrity": "sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.0.tgz", + "integrity": "sha512-avCea0RAP03lTsDhEyfy+hpfr85KfyTctMADqHVhLAF3MlIkq83CP8UfAHUssgXTYd+6er6PaAhx/QGv4L1EiA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.0.tgz", + "integrity": "sha512-IWfdwU7KDSm07Ty0PuA/W2JYoZ4iTj3TUQjkVsO/6U+4I1jN5lcR71ZEvRh52sDOERdnNhhHU57UITXz5jC1/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.0.tgz", + "integrity": "sha512-n2LMsUz7Ynu7DoQrSQkBf8iNrjOGyPLrdSg802vk6XT3FtsgX6JbE8IHRvposskFm9SNxzkLYGSq9QdpLYpRNA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.0.tgz", + "integrity": "sha512-C/zbRYRXFjWvz9Z4haRxcTdnkPt1BtCkz+7RtBSuNmKzMzp3ZxdM28Mpccn6pt28/UWUCTXa+b0Mx1k3g6NOMA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.0.tgz", + "integrity": "sha512-l3m9ewPgjQSXrUMHg93vt0hYCGnrMOcUpTz6FLtbwljo2HluS4zTXFy2571YQbisTnfTKPZ01u/ukJdQTLGh9A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.0.tgz", + "integrity": "sha512-rJ5D47d8WD7J+7STKdCUAgmQk49xuFrRi9pZkWoRD1UeSMakbcepWXPF8ycChBoAqs1pb2wzvbY6Q33WmN2ftw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.0.tgz", + "integrity": "sha512-be6Yx37b24ZwxQ+wOQXXLZqpq4jTckJhtGlWGZs68TgdKXJgw54lUUoFYrg6Zs/kjzAQwEwYbp8JxZVzZLRepQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.0.tgz", + "integrity": "sha512-hNVMQK+qrA9Todu9+wqrXOHxFiD5YmdEi3paj6vP02Kx1hjd2LLYR2eaN7DsEshg09+9uzWi2W18MJDlG0cxJA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.0.tgz", + "integrity": "sha512-ROCM7i+m1NfdrsmvwSzoxp9HFtmKGHEqu5NNDiZWQtXLA8S5HBCkVvKAxJ8U+CVctHwV2Gb5VUaK7UAkzhDjlg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.0.tgz", + "integrity": "sha512-0UyyRHyDN42QL+NbqevXIIUnKA47A+45WyasO+y2bGJ1mhQrfrtXUpTxCOrfxCR4esV3/RLYyucGVPiUsO8xjg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz", + "integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.0.tgz", + "integrity": "sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.0.tgz", + "integrity": "sha512-7J6TkZQFGo9qBKH0pk2cEVSRhJbL6MtfWxth7Y5YmZs57Pi+4x6c2dStAUvaQkHQLnEQv1jzBUW43GvZW8OFqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.0.tgz", + "integrity": "sha512-Txjh+IxBPbkUB9+SXZMpv+b/vnTEtFyfWZgJ6iyCmt2tdx0OF5WhFowLmnh8ENGNpfUlUZkdI//4IEmhwPieNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz", + "integrity": "sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "dev": true + }, + "node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.0.tgz", + "integrity": "sha512-QmJz14PX3rzbJCN1SG4Xe/bAAX2a6NpCP8ab2vfu2GiUr8AQcr2nCV/oEO3yneFarB67zk8ShlIyWb2LGTb3Sg==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.5" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.18.0", + "@rollup/rollup-android-arm64": "4.18.0", + "@rollup/rollup-darwin-arm64": "4.18.0", + "@rollup/rollup-darwin-x64": "4.18.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.18.0", + "@rollup/rollup-linux-arm-musleabihf": "4.18.0", + "@rollup/rollup-linux-arm64-gnu": "4.18.0", + "@rollup/rollup-linux-arm64-musl": "4.18.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.18.0", + "@rollup/rollup-linux-riscv64-gnu": "4.18.0", + "@rollup/rollup-linux-s390x-gnu": "4.18.0", + "@rollup/rollup-linux-x64-gnu": "4.18.0", + "@rollup/rollup-linux-x64-musl": "4.18.0", + "@rollup/rollup-win32-arm64-msvc": "4.18.0", + "@rollup/rollup-win32-ia32-msvc": "4.18.0", + "@rollup/rollup-win32-x64-msvc": "4.18.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "dev": true, + "engines": { + "node": ">=0.10.0" } }, "node_modules/typescript": { @@ -24,6 +755,61 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/vite": { + "version": "5.2.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.12.tgz", + "integrity": "sha512-/gC8GxzxMK5ntBwb48pR32GGhENnjtY30G4A0jemunsBkiEZFw60s8InGpN8gkhHEkjnRK1aSAxeQgwvFhUHAA==", + "dev": true, + "dependencies": { + "esbuild": "^0.20.1", + "postcss": "^8.4.38", + "rollup": "^4.13.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } } } } diff --git a/sketches/2024-05-30-common-frp/package.json b/sketches/2024-05-30-common-frp/package.json index 84a32a4b3..825ac5858 100644 --- a/sketches/2024-05-30-common-frp/package.json +++ b/sketches/2024-05-30-common-frp/package.json @@ -4,7 +4,10 @@ "description": "Common functional reactive programming utilities", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "build": "vite build", + "preview": "vite preview", + "dev": "vite" }, "keywords": [ "frp" @@ -12,6 +15,7 @@ "author": "Gordon Brander", "license": "MIT", "devDependencies": { - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "vite": "^5.2.12" } } diff --git a/sketches/2024-05-30-common-frp/src/common-frp.ts b/sketches/2024-05-30-common-frp/src/common-frp.ts index 1758ee3ad..fc1e76db3 100644 --- a/sketches/2024-05-30-common-frp/src/common-frp.ts +++ b/sketches/2024-05-30-common-frp/src/common-frp.ts @@ -2,28 +2,23 @@ export const config = { debug: false } -const debugLog = (msg: string) => { +const debugLog = (tag: string, msg: string) => { if (config.debug) { - console.debug(msg) + console.debug(`[${tag}] ${msg}`) } } export const chooseLeft = (left: T, _right: T) => left -class TransactionQueue { - name: string - #queue = new Map<(value: any) => void, any>() +const TransactionQueue = (name: string) => { + const queue = new Map<(value: any) => void, any>() - constructor(name: string) { - this.name = name - } - - transact() { - debugLog(`TransactionQueue ${this.name}: transacting ${this.#queue.size} jobs`) - for (const [perform, value] of this.#queue) { + const transact = () => { + debugLog(name, `transacting ${queue.size} jobs`) + for (const [perform, value] of queue) { perform(value) } - this.#queue.clear() + queue.clear() } /** @@ -35,20 +30,22 @@ class TransactionQueue { * @param choose - A function to choose between two values if more than one * has been set during the transaction (default is chooseLeft) */ - withTransaction = ( + const withTransaction = ( perform: (value: T) => void, value: T, choose: (left: T, right: T) => T = chooseLeft ) => { - const left = this.#queue.get(perform) - this.#queue.set(perform, left !== undefined ? choose(left, value) : value) + const left = queue.get(perform) + queue.set(perform, left !== undefined ? choose(left, value) : value) } + + return {transact, withTransaction} } const TransactionManager = () => { - const streamQueue = new TransactionQueue('streams') - const cellQueue = new TransactionQueue('cells') - const computedQueue = new TransactionQueue('computed') + const streamQueue = TransactionQueue('streams') + const cellQueue = TransactionQueue('cells') + const computedQueue = TransactionQueue('computed') let isScheduled = false @@ -56,25 +53,25 @@ const TransactionManager = () => { if (isScheduled) { return } - debugLog(`TransactionManager: transaction scheduled`) + debugLog('TransactionManager', `transaction scheduled`) isScheduled = true queueMicrotask(transact) } const transact = () => { - debugLog(`TransactionManager: transaction start`) - debugLog(`TransactionManager: transact events`) + debugLog('TransactionManager', 'transaction start') + debugLog('TransactionManager', 'transact events') // First perform all events streamQueue.transact() - debugLog(`TransactionManager: transact cells`) + debugLog('TransactionManager', 'transact cells') // Then perform all cell updates cellQueue.transact() - debugLog(`TransactionManager: transact computed`) + debugLog('TransactionManager', 'transact computed') // Finally, update computed cells computedQueue.transact() isScheduled = false - debugLog(`TransactionManager: transaction end`) + debugLog('TransactionManager', 'transaction end') } const withCells = ( @@ -82,6 +79,7 @@ const TransactionManager = () => { value: T, choose: (left: T, right: T) => T = chooseLeft ) => { + debugLog('withCells', 'queue job') cellQueue.withTransaction(perform, value, choose) schedule() } @@ -91,6 +89,7 @@ const TransactionManager = () => { value: T, choose: (left: T, right: T) => T = chooseLeft ) => { + debugLog('withStreams', 'queue job') streamQueue.withTransaction(perform, value, choose) schedule() } @@ -100,6 +99,7 @@ const TransactionManager = () => { value: T, choose: (left: T, right: T) => T = chooseLeft ) => { + debugLog('withComputed', 'queue job') computedQueue.withTransaction(perform, value, choose) schedule() } @@ -109,11 +109,13 @@ const TransactionManager = () => { const {withCells, withStreams, withComputed} = TransactionManager() -export type Send = (value: T) => void export type Cancel = () => void -type Topic = { - notify: (value: T) => void, +export type Send = { + send: (value: T) => void +} + +export type Sink = { sink: (subscriber: Send) => Cancel } @@ -121,12 +123,15 @@ type Topic = { * Create one-to-many event broadcast channel for a list of subscribers. * Publishes synchronously to all subscribers. */ -const Topic = (): Topic => { - const subscribers = new Set<(value: T) => void>() +const Topic = () => { + const subscribers = new Set>() - const notify = (value: T) => { + /** Get subscribers */ + const subs = () => subscribers.values() + + const send = (value: T) => { for (const subscriber of subscribers) { - subscriber(value) + subscriber.send(value) } } @@ -135,7 +140,7 @@ const Topic = (): Topic => { return () => subscribers.delete(subscriber) } - return {notify, sink} + return {send, sink, subs} } const combineCancels = (cancels: Array): Cancel => () => { @@ -144,152 +149,118 @@ const combineCancels = (cancels: Array): Cancel => () => { } } -type Streamable = { - sink: (subscriber: (value: T) => void) => () => void +export const Stream = () => { + const topic = Topic() + const send = (value: T) => withStreams(topic.send, value) + const sink = (subscriber: Send) => topic.sink(subscriber) + return {send, sink} } -export class Stream implements Streamable { - #topic: Topic - #choose: (left: T, right: T) => T - - constructor( - choose: (left: T, right: T) => T = chooseLeft - ) { - this.#topic = Topic() - this.#choose = choose - } - - send(value: T) { - withStreams(this.#topic.notify, value, this.#choose) - } +const noOp = () => {} - sink(subscriber: (value: T) => void) { - return this.#topic.sink(subscriber) - } -} - -export class ReadOnlyStream implements Streamable { - #stream: Streamable - - constructor(stream: Stream) { - this.#stream = stream - } - - sink(subscriber: (value: T) => void) { - return this.#stream.sink(subscriber) - } -} - -export const useStream = ( - produce: (send: Send) => void +/** + * Generate a stream using a callback. + * Returns a read-only stream. + */ +export const generateStream = ( + produce: (send: (value: T) => void) => Cancel|void ) => { - const downstream = new Stream() - produce(value => downstream.send(value)) - return new ReadOnlyStream(downstream) + const {send, sink} = Stream() + const cancel = produce(send) ?? noOp + return {cancel, sink} } export const mapStream = ( - upstream: Stream, + upstream: Sink, transform: (value: T) => U -) => useStream(send => { - upstream.sink(value => send(transform(value))) +) => generateStream(send => { + return upstream.sink({ + send: value => send(transform(value)) + }) }) -export const scanStream = ( - upstream: Stream, - step: (state: U, value: T) => U, - initial: U -) => useStream(send => { - let state = initial - upstream.sink(value => { - state = step(state, value) - send(state) +export const filterStream = ( + upstream: Sink, + predicate: (value: T) => boolean +) => generateStream(send => { + return upstream.sink({ + send: value => { + if (predicate(value)) { + send(value) + } + } }) }) const isEqual = Object.is -export type Cellable = { - readonly value: T, - sink: (subscriber: (value: T) => void) => () => void +export type Gettable = { + get(): T } -export class Cell implements Cellable { - #name: string - #value: T - #topic = Topic() +export const get = (container: Gettable) => container.get() - constructor(value: T, name: string) { - this.#value = value - this.#name = name - } +export type CellLike = Gettable & Sink - get name() { - return this.#name - } +export const Cell = (initial: T, name: string) => { + const topic = Topic() + let state = initial - get value() { - return this.#value - } + const get = () => state + const getName = () => name - #setState = (value: T) => { - // Only notify downstream if state has changed value - if (!isEqual(this.#value, value)) { - this.#value = value - this.#topic.notify(value) - } + const setState = (value: T) => { + // Only notify downstream if state has changed value + if (!isEqual(state, value)) { + state = value + topic.send(value) + } } - send(value: T) { - withCells(this.#setState, value) + const send = (value: T) => { + withCells(setState, value) } - sink(subscriber: Send) { - subscriber(this.#value) - return this.#topic.sink(subscriber) + const sink = (subscriber: Send) => { + subscriber.send(state) + return topic.sink(subscriber) } + + return {get, name: getName, send, sink} } -export const getCell = (cell: Cell) => cell.value - -export class ComputedCell implements Cellable { - #topic = Topic() - #isDirty = false - #value: T - #recalc: () => T - #upstreams: Array> - cancel: Cancel - - constructor( - upstreams: Array>, - calc: (...values: Array) => T - ) { - this.#upstreams = upstreams - this.#recalc = () => calc(...this.#upstreams.map(getCell)) - this.#value = this.#recalc() - - this.cancel = combineCancels( - upstreams.map(cell => cell.sink(value => { - withComputed(this.#markDirty, value) - })) - ) - } +export const Computed = ( + upstreams: Array>, + calc: (...values: Array) => T +) => { + const topic = Topic() - #markDirty = () => { - this.#isDirty = true - } + const recalc = (): T => calc(...upstreams.map(get)) + + let isDirty = false + let state = recalc() + + const markDirty = () => isDirty = true - get value() { - if (this.#isDirty) { - this.#value = this.#recalc() - this.#isDirty = false + const subject = { + send: (value: T) => { + withComputed(markDirty, value) } - return this.#value } - sink(subscriber: Send): () => void { - return this.#topic.sink(subscriber) + const cancel = combineCancels(upstreams.map(cell => cell.sink(subject))) + + const get = () => { + if (isDirty) { + state = recalc() + isDirty = false + } + return state } + + const sink = (subscriber: Send) => topic.sink(subscriber) + + return {get, sink, cancel} } /** @@ -298,9 +269,10 @@ export class ComputedCell implements Cellable { * @param initial - the initial value for the cell * @returns cell */ -export const hold = (stream: Stream, initial: T): ComputedCell => { - const cell = new Cell(initial, 'hold') - // TODO deal with cancel - stream.sink(value => cell.send(value)) - return new ComputedCell([cell], (value) => value) +export const hold = (stream: Sink, initial: T) => { + const {get, sink, send} = Cell(initial, 'hold') + const cancel = stream.sink({ + send: value => send(value) + }) + return {get, sink, cancel} } diff --git a/sketches/2024-05-30-common-frp/src/example/basic.ts b/sketches/2024-05-30-common-frp/src/example/basic.ts new file mode 100644 index 000000000..30cf09d8f --- /dev/null +++ b/sketches/2024-05-30-common-frp/src/example/basic.ts @@ -0,0 +1,15 @@ +import {Cell, Computed, config} from '../common-frp' + +config.debug = true + +const a = Cell(0, 'foo') + +setInterval(() => { + a.send(a.get() + 1) +}, 1000) + +const b = Computed([a], (a) => a + 1) + +b.sink({ + send: (b) => console.log(b) +}) \ No newline at end of file From 1b3db0986ccc28f9b96970014994bc488d119d88 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Tue, 4 Jun 2024 09:01:19 -0700 Subject: [PATCH 08/30] Microtask queue-based topological sort - Allows for glitch-free reactivity. - Does not allow for cycles - To allow cycles, we'll need to complete the implementation in experimental --- sketches/2024-05-30-common-frp/NOTES.md | 40 ++- sketches/2024-05-30-common-frp/README.md | 20 +- sketches/2024-05-30-common-frp/index.html | 1 + .../2024-05-30-common-frp/src/common-frp.ts | 215 ++++--------- sketches/2024-05-30-common-frp/src/dom.ts | 9 + .../src/example/basic.ts | 25 +- .../experimental/common-frp-transaction.ts | 304 ++++++++++++++++++ 7 files changed, 449 insertions(+), 165 deletions(-) create mode 100644 sketches/2024-05-30-common-frp/src/dom.ts create mode 100644 sketches/2024-05-30-common-frp/src/experimental/common-frp-transaction.ts diff --git a/sketches/2024-05-30-common-frp/NOTES.md b/sketches/2024-05-30-common-frp/NOTES.md index cbf4a1857..cc075baec 100644 --- a/sketches/2024-05-30-common-frp/NOTES.md +++ b/sketches/2024-05-30-common-frp/NOTES.md @@ -2,6 +2,29 @@ Rough notes and references while designing. +## Design + +- Discrete classical FRP + - Streams + - Events over time. Update during a moment. + - Independent. They may not depend upon each other's state. + - They may depend upon cells, but must get the cell's state before + the cell graph is updated. + - They act as IO input to the cell graph. + - Cells + - Reactive containers for state. Always have a value. + - Computed Cells + - Reactive computed states, derived from cells and other computed cells. + +## Implementation + +- Transaction + - Update streams: Update streams: + - Update cells: mutate cell state and mark computed cells dirty (push) + - Dispatch "I am dirty" notification immediately during cell update phase to downstream cells + - Update sinks: get updated cell and computed state. Computed state is recomputed if dirty. + - Subscribe with sinks + ## Prior art ### Classical FRP @@ -16,8 +39,23 @@ Qualities: - Resulting computation graph is pure. - Theory pioneered by Conal Elliott. - Full Turing-complete theory of reactive computation. +- 10 primitives: + - map, merge, hold, snapshot, filter, lift, never, constant, sample, and switch. (Functional Reactive Programming, 2.3, Manning) + +> Each FRP system has its own policy for merging simultaneous events. Sodium’s policy is as follows: +> +> - If the input events on the two input streams are simultaneous, merge combines them into one. merge takes a combining function as a second argument for this purpose. The signature of the combining function is A combine(A left, A right). +> -The combining function is not used in the (usually more common) case where the input events are not simultaneous. +> -You invoke merge like this: s1.merge(s2, f). If merge needs to combine simul- taneous events, the event from s1 is passed as the left argument of the combin- ing function f, and the event from s2 is passed on the right. +> -The s1.orElse(s2) variant of merge doesn’t take a combining function. In the simultaneous case, the left s1 event takes precedence and the right s2 event is dropped. This is equivalent to s1.merge(s2, (l, r) -> l). The name orElse() was chosen to remind you to be careful, because events can be dropped. +> +> This policy has some nice results: +> - There can only ever be one event per transaction in a given stream. +> - There’s no such thing as event-processing order within a transaction. All events that occur in different streams within the same transaction are truly simultaneous in that there’s no detectable order between them. +> +> (Functional Reactive Programming, 2.6.1, Manning) -Libraires: +Libraries: - [Sodium FRP](https://github.com/SodiumFRP) - [Sodium Typescript](https://github.com/SodiumFRP/sodium-typescript/tree/master/src/lib/sodium) diff --git a/sketches/2024-05-30-common-frp/README.md b/sketches/2024-05-30-common-frp/README.md index 568d17894..7b4626a68 100644 --- a/sketches/2024-05-30-common-frp/README.md +++ b/sketches/2024-05-30-common-frp/README.md @@ -4,15 +4,15 @@ Common functional reactive programming utilities. ## Goals -- [ ] Classical FRP with behaviors and events (also called cells and streams, or signals and streams) -- [ ] 1st order FRP, static graph +- [x] Classical FRP with behaviors and events (also called cells and streams, or signals and streams) + - [x] Behaviors (called cells) are reactive containers for state that can be formalized as functions of time + - [x] Events (called streams) are streams of events over time +- [x] 1st order FRP (static graph) - [ ] Graph description can be serialized to JSON -- [ ] Glitch free: updates happen during discrete moments using a transaction system -- [ ] Cycles - -## Todo - + - [x] All graph dependencies held in array (in contrast to S.js style or Signals style where graph is defined via VM execution trace) +- [x] Glitch free: updates happen during discrete moments using a transaction system +- [x] Transformations should happen during same transaction - [ ] Transactions -- [ ] Transformations should happen during same transaction -- [ ] Cell to stream happens one transaction later - + - [ ] (see experimental for WIP) +- [ ] Cycles + - [ ] TODO: switch from microtask-based topological sorting to transaction-based push-pull (see experimental for WIP) diff --git a/sketches/2024-05-30-common-frp/index.html b/sketches/2024-05-30-common-frp/index.html index c2377acd1..4b276d716 100644 --- a/sketches/2024-05-30-common-frp/index.html +++ b/sketches/2024-05-30-common-frp/index.html @@ -7,5 +7,6 @@ + diff --git a/sketches/2024-05-30-common-frp/src/common-frp.ts b/sketches/2024-05-30-common-frp/src/common-frp.ts index fc1e76db3..e0887ba44 100644 --- a/sketches/2024-05-30-common-frp/src/common-frp.ts +++ b/sketches/2024-05-30-common-frp/src/common-frp.ts @@ -8,134 +8,53 @@ const debugLog = (tag: string, msg: string) => { } } -export const chooseLeft = (left: T, _right: T) => left - -const TransactionQueue = (name: string) => { - const queue = new Map<(value: any) => void, any>() - - const transact = () => { - debugLog(name, `transacting ${queue.size} jobs`) - for (const [perform, value] of queue) { - perform(value) - } - queue.clear() - } - - /** - * Queue a job to perform during the next transaction, - * along with a value to pass to the job - * @param perform - The function to perform (used as the unique key in - * the queue) - * @param value - The value to pass to the perform function - * @param choose - A function to choose between two values if more than one - * has been set during the transaction (default is chooseLeft) - */ - const withTransaction = ( - perform: (value: T) => void, - value: T, - choose: (left: T, right: T) => T = chooseLeft - ) => { - const left = queue.get(perform) - queue.set(perform, left !== undefined ? choose(left, value) : value) - } - - return {transact, withTransaction} -} - -const TransactionManager = () => { - const streamQueue = TransactionQueue('streams') - const cellQueue = TransactionQueue('cells') - const computedQueue = TransactionQueue('computed') - +export const batched = (perform: (value: T) => void) => { let isScheduled = false - - const schedule = () => { - if (isScheduled) { - return + let state: T | undefined = undefined + const schedule = (value: T) => { + state = value + if (!isScheduled) { + isScheduled = true + queueMicrotask(() => { + debugLog('batched', `performing ${state}`) + perform(state!) + isScheduled = false + }) } - debugLog('TransactionManager', `transaction scheduled`) - isScheduled = true - queueMicrotask(transact) - } - - const transact = () => { - debugLog('TransactionManager', 'transaction start') - debugLog('TransactionManager', 'transact events') - // First perform all events - streamQueue.transact() - debugLog('TransactionManager', 'transact cells') - // Then perform all cell updates - cellQueue.transact() - - debugLog('TransactionManager', 'transact computed') - // Finally, update computed cells - computedQueue.transact() - isScheduled = false - debugLog('TransactionManager', 'transaction end') - } - - const withCells = ( - perform: (value: T) => void, - value: T, - choose: (left: T, right: T) => T = chooseLeft - ) => { - debugLog('withCells', 'queue job') - cellQueue.withTransaction(perform, value, choose) - schedule() } - - const withStreams = ( - perform: (value: T) => void, - value: T, - choose: (left: T, right: T) => T = chooseLeft - ) => { - debugLog('withStreams', 'queue job') - streamQueue.withTransaction(perform, value, choose) - schedule() - } - - const withComputed = ( - perform: (value: T) => void, - value: T, - choose: (left: T, right: T) => T = chooseLeft - ) => { - debugLog('withComputed', 'queue job') - computedQueue.withTransaction(perform, value, choose) - schedule() - } - - return {withCells, withStreams, withComputed} + return schedule } -const {withCells, withStreams, withComputed} = TransactionManager() - export type Cancel = () => void -export type Send = { +export type Subscriber = { send: (value: T) => void } export type Sink = { - sink: (subscriber: Send) => Cancel + sink: (subscriber: Subscriber) => Cancel } /** * Create one-to-many event broadcast channel for a list of subscribers. - * Publishes synchronously to all subscribers. + * This is a low-level helper that publishes *synchronously* to all subscribers. + * We use this to implement higher-level abstractions like streams and cells, + * which dispatch using a shared transaction system. */ -const Topic = () => { - const subscribers = new Set>() +export const createPublisher = () => { + const subscribers = new Set>() /** Get subscribers */ const subs = () => subscribers.values() const send = (value: T) => { + debugLog('publisher', `dispatching ${value} to ${subscribers.size} subscribers`) for (const subscriber of subscribers) { subscriber.send(value) } } - const sink = (subscriber: Send): Cancel => { + const sink = (subscriber: Subscriber): Cancel => { subscribers.add(subscriber) return () => subscribers.delete(subscriber) } @@ -143,17 +62,20 @@ const Topic = () => { return {send, sink, subs} } -const combineCancels = (cancels: Array): Cancel => () => { +export const combineCancels = (cancels: Array): Cancel => () => { for (const cancel of cancels) { cancel() } } -export const Stream = () => { - const topic = Topic() - const send = (value: T) => withStreams(topic.send, value) - const sink = (subscriber: Send) => topic.sink(subscriber) - return {send, sink} +export const createStream = () => { + const updatesPublisher = createPublisher() + const send = batched((value: T) => { + debugLog('stream', `sending ${value}`) + updatesPublisher.send(value) + }) + const sink = updatesPublisher.sink + return {name, send, sink} } const noOp = () => {} @@ -165,7 +87,7 @@ const noOp = () => {} export const generateStream = ( produce: (send: (value: T) => void) => Cancel|void ) => { - const {send, sink} = Stream() + const {send, sink} = createStream() const cancel = produce(send) ?? noOp return {cancel, sink} } @@ -173,7 +95,7 @@ export const generateStream = ( export const mapStream = ( upstream: Sink, transform: (value: T) => U -) => generateStream(send => { +) => generateStream((send: (value: U) => void) => { return upstream.sink({ send: value => send(transform(value)) }) @@ -198,68 +120,65 @@ export type Gettable = { get(): T } -export const get = (container: Gettable) => container.get() +export const sample = (container: Gettable) => container.get() -export type CellLike = Gettable & Sink +export type CellLike = { + get: () => T + sink(subscriber: Subscriber): Cancel +} -export const Cell = (initial: T, name: string) => { - const topic = Topic() +export const createCell = (initial: T, name: string) => { + const publisher = createPublisher() let state = initial const get = () => state - const getName = () => name - const setState = (value: T) => { + const send = batched((value: T) => { // Only notify downstream if state has changed value if (!isEqual(state, value)) { state = value - topic.send(value) + debugLog('cell', `updated ${state}`) + publisher.send(state) } - } - - const send = (value: T) => { - withCells(setState, value) - } + }) - const sink = (subscriber: Send) => { + const sink = (subscriber: Subscriber) => { subscriber.send(state) - return topic.sink(subscriber) + return publisher.sink(subscriber) } - return {get, name: getName, send, sink} + return {name, get, send, sink} } -export const Computed = ( +export const createComputed = ( upstreams: Array>, - calc: (...values: Array) => T + compute: (...values: Array) => T ) => { - const topic = Topic() + const publisher = createPublisher() - const recalc = (): T => calc(...upstreams.map(get)) + const recompute = (): T => compute(...upstreams.map(sample)) - let isDirty = false - let state = recalc() + let state = recompute() - const markDirty = () => isDirty = true + const get = () => state const subject = { - send: (value: T) => { - withComputed(markDirty, value) - } + send: batched(_value => { + state = recompute() + debugLog('computed', `recomputed ${state}`) + publisher.send(state) + }) } const cancel = combineCancels(upstreams.map(cell => cell.sink(subject))) - const get = () => { - if (isDirty) { - state = recalc() - isDirty = false - } - return state + const sink = ( + subscriber: Subscriber + ) => { + subscriber.send(state) + return publisher.sink(subscriber) } - const sink = (subscriber: Send) => topic.sink(subscriber) - return {get, sink, cancel} } @@ -269,10 +188,8 @@ export const Computed = ( * @param initial - the initial value for the cell * @returns cell */ -export const hold = (stream: Sink, initial: T) => { - const {get, sink, send} = Cell(initial, 'hold') - const cancel = stream.sink({ - send: value => send(value) - }) - return {get, sink, cancel} +export const hold = (stream: Sink, initial: T, name: string) => { + const cell = createCell(initial, name) + const cancel = stream.sink(cell) + return {get: cell.get, sink: cell.sink, cancel} } diff --git a/sketches/2024-05-30-common-frp/src/dom.ts b/sketches/2024-05-30-common-frp/src/dom.ts new file mode 100644 index 000000000..007750041 --- /dev/null +++ b/sketches/2024-05-30-common-frp/src/dom.ts @@ -0,0 +1,9 @@ +import { generateStream } from './common-frp' + +export const events = ( + element: HTMLElement, + name: string +) => generateStream((send: (value: Event) => void) => { + element.addEventListener(name, send) + return () => element.removeEventListener(name, send) +}) \ No newline at end of file diff --git a/sketches/2024-05-30-common-frp/src/example/basic.ts b/sketches/2024-05-30-common-frp/src/example/basic.ts index 30cf09d8f..2fcbf741d 100644 --- a/sketches/2024-05-30-common-frp/src/example/basic.ts +++ b/sketches/2024-05-30-common-frp/src/example/basic.ts @@ -1,15 +1,30 @@ -import {Cell, Computed, config} from '../common-frp' +import {createCell, createComputed, hold, mapStream, config} from '../common-frp' +import { events } from '../dom' config.debug = true -const a = Cell(0, 'foo') +const button = document.getElementById('button')! + +const clicks = events(button, 'click') + +const xPos = mapStream(clicks, (event) => { + const mouseEvent = event as MouseEvent + return mouseEvent.clientX +}) + +const currentX = hold(xPos, 0, 'currentX') + +const a = createCell(0, 'foo') setInterval(() => { a.send(a.get() + 1) }, 1000) -const b = Computed([a], (a) => a + 1) +const b = createComputed([a, currentX], (a) => a + 1) +const c = createComputed([a, currentX], (a) => a + 2) +const d = createComputed([b, c], (b, c) => b + c) -b.sink({ - send: (b) => console.log(b) +// You should only see one log message per update +d.sink({ + send: (x) => console.log(x) }) \ No newline at end of file diff --git a/sketches/2024-05-30-common-frp/src/experimental/common-frp-transaction.ts b/sketches/2024-05-30-common-frp/src/experimental/common-frp-transaction.ts new file mode 100644 index 000000000..91dbfb821 --- /dev/null +++ b/sketches/2024-05-30-common-frp/src/experimental/common-frp-transaction.ts @@ -0,0 +1,304 @@ +/* +TODO: beginnings of a push-pull FRP system using transactions. +This will enable graph cycles. +*/ +export const config = { + debug: false +} + +const debugLog = (tag: string, msg: string) => { + if (config.debug) { + console.debug(`[${tag}] ${msg}`) + } +} + +export const chooseLeft = (left: T, _right: T) => left + +type TransactionQueue = { + name: string, + transact: () => void + withTransaction: ( + perform: (value: T) => void, + value: T, + choose: (left: T, right: T) => T + ) => void +} + +const createTransactionQueue = (name: string): TransactionQueue => { + const queue = new Map<(value: any) => void, any>() + + const transact = () => { + debugLog(name, `transacting ${queue.size} jobs`) + for (const [perform, value] of queue) { + perform(value) + } + queue.clear() + } + + /** + * Queue a job to perform during the next transaction, + * along with a value to pass to the job + * @param perform - The function to perform (used as the unique key in + * the queue) + * @param value - The value to pass to the perform function + * @param choose - A function to choose between two values if more than one + * has been set during the transaction (default is chooseLeft) + */ + const withTransaction = ( + perform: (value: T) => void, + value: T, + choose: (left: T, right: T) => T = chooseLeft + ) => { + const left = queue.get(perform) + queue.set(perform, left !== undefined ? choose(left, value) : value) + } + + return {name, transact, withTransaction} +} + +const createTransactionManager = () => { + const streams = createTransactionQueue('streams') + const cells = createTransactionQueue('cells') + const computed = createTransactionQueue('computed') + const sinks = createTransactionQueue('sinks') + + let isScheduled = false + + const schedule = () => { + if (isScheduled) { + return + } + debugLog('TransactionManager', `transaction scheduled`) + isScheduled = true + queueMicrotask(transact) + } + + const transact = () => { + debugLog('TransactionManager', 'transaction start') + // First perform all event stream updates + streams.transact() + // Then perform all cell state updates + cells.transact() + // Finally, update sinks + sinks.transact() + isScheduled = false + debugLog('TransactionManager', 'transaction end') + } + + const withPhase = ( + queue: TransactionQueue + ) => ( + perform: (value: T) => void, + value: T, + choose: (left: T, right: T) => T = chooseLeft + ) => { + debugLog(queue.name, 'queue job') + queue.withTransaction(perform, value, choose) + schedule() + } + + const withStreams = withPhase(streams) + const withCells = withPhase(cells) + const withComputed = withPhase(computed) + const withSinks = withPhase(sinks) + + return {withCells, withStreams, withComputed, withSinks} +} + +const { + withCells, + withStreams, + withComputed, + withSinks +} = createTransactionManager() + +export type Cancel = () => void + +export type Subscriber = { + send: (value: T) => void +} + +export type Sink = { + sink: (subscriber: Subscriber) => Cancel +} + +/** + * Create one-to-many event broadcast channel for a list of subscribers. + * This is a low-level helper that publishes *synchronously* to all subscribers. + * We use this to implement higher-level abstractions like streams and cells, + * which dispatch using a shared transaction system. + */ +export const createPublisher = () => { + const subscribers = new Set>() + + /** Get subscribers */ + const subs = () => subscribers.values() + + const send = (value: T) => { + for (const subscriber of subscribers) { + subscriber.send(value) + } + } + + const sink = (subscriber: Subscriber): Cancel => { + subscribers.add(subscriber) + return () => subscribers.delete(subscriber) + } + + return {send, sink, subs} +} + +export const combineCancels = (cancels: Array): Cancel => () => { + for (const cancel of cancels) { + cancel() + } +} + +export const createStream = (choose = chooseLeft) => { + const updatesPublisher = createPublisher() + const send = (value: T) => withStreams(updatesPublisher.send, value, choose) + const sink = (subscriber: Subscriber) => updatesPublisher.sink(subscriber) + return {send, sink} +} + +const noOp = () => {} + +/** + * Generate a stream using a callback. + * Returns a read-only stream. + */ +export const generateStream = ( + produce: (send: (value: T) => void) => Cancel|void +) => { + const {send, sink} = createStream() + const cancel = produce(send) ?? noOp + return {cancel, sink} +} + +export const mapStream = ( + upstream: Sink, + transform: (value: T) => U +) => generateStream((send: (value: U) => void) => { + return upstream.sink({ + send: value => send(transform(value)) + }) +}) + +export const filterStream = ( + upstream: Sink, + predicate: (value: T) => boolean +) => generateStream(send => { + return upstream.sink({ + send: value => { + if (predicate(value)) { + send(value) + } + } + }) +}) + +const isEqual = Object.is + +export type Gettable = { + get(): T +} + +export const sample = (container: Gettable) => container.get() + +export type CellLike = { + get(): T + onChange(subscriber: Subscriber): Cancel +} + +export const createCell = (initial: T, name: string) => { + const dirtyPublisher = createPublisher() + let state = initial + + const get = () => state + const getName = () => name + + const setState = (value: T) => { + // Only notify downstream if state has changed value + if (!isEqual(state, value)) { + state = value + dirtyPublisher.send() + } + } + + const send = (value: T) => { + withCells(setState, value) + } + + const onChange = (subscriber: Subscriber) => { + subscriber.send() + return dirtyPublisher.sink(subscriber) + } + + return {get, name: getName, send, onChange} +} + +export const createComputed = ( + upstreams: Array>, + calc: (...values: Array) => T +) => { + const dirtyPublisher = createPublisher() + + const recompute = (): T => calc(...upstreams.map(sample)) + + let isDirty = false + let state = recompute() + + // TODO need to actually dispatch to sinks + // Think about this... when does a computed cell recalculate? + const markDirty = () => { + isDirty = true + dirtyPublisher.send() + } + + const subject = { + send: markDirty + } + + const cancel = combineCancels(upstreams.map(cell => cell.onChange(subject))) + + const get = () => { + if (isDirty) { + state = recompute() + isDirty = false + } + return state + } + + const onChange = ( + subscriber: Subscriber + ) => { + subscriber.send() + dirtyPublisher.sink(subscriber) + } + + return {get, onChange, cancel} +} + +/** + * "Hold" the latest value from a stream in a cell + * @param stream - the stream to update cell + * @param initial - the initial value for the cell + * @returns cell + */ +export const hold = (stream: Sink, initial: T) => { + const {get, onChange, send} = createCell(initial, 'hold') + const cancel = stream.sink({ + send: value => send(value) + }) + return {get, onChange, cancel} +} + +export const createEffect = ( + cell: CellLike, + receive: (value: T) => void +) => { + const send = (cell: CellLike) => receive(cell.get()) + cell.onChange({ + send: () => withSinks(send, cell) + }) +} \ No newline at end of file From 09689afeb3c240638c49b497f7b9c2d95dd34bde Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Tue, 4 Jun 2024 11:41:22 -0700 Subject: [PATCH 09/30] More notes --- sketches/2024-05-30-common-frp/NOTES.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/sketches/2024-05-30-common-frp/NOTES.md b/sketches/2024-05-30-common-frp/NOTES.md index cc075baec..f1f664467 100644 --- a/sketches/2024-05-30-common-frp/NOTES.md +++ b/sketches/2024-05-30-common-frp/NOTES.md @@ -110,3 +110,23 @@ The distinction between cold and hot observables (or their equivalent) is a sign - Hot observable: Multicast. Maintains a list of subscribers and dispatches to them from a single data producing source. - Examples: [share](https://rxjs.dev/api/index/function/share) in RxJS. - Has to deal with callback cleanup bookkeeping in dynamic graphs. + +### Pipeable operators + +Following RxJS, we enable piping through unary functions. This is a functional alternative to method chaining. + +> Problems with the patched operators for dot-chaining are: +> +> Any library that imports a patch operator will augment the Observable.prototype for all consumers of that library, creating blind dependencies. If the library removes their usage, they unknowingly break everyone else. With pipeables, you have to import the operators you need into each file you use them in. +> +> Operators patched directly onto the prototype are not "tree-shakeable" by tools like rollup or webpack. Pipeable operators will be as they are just functions pulled in from modules directly. +> +> Unused operators that are being imported in apps cannot be detected reliably by any sort of build tool or lint rule. That means that you might import scan, but stop using it, and it's still being added to your output bundle. With pipeable operators, if you're not using it, a lint rule can pick it up for you. +> +> Functional composition is awesome. Building your own custom operators becomes much easier, and now they work and look just like all other operators in rxjs. You don't need to extend Observable or override lift anymore. + +> [Pipeable Operators](https://v6.rxjs.dev/guide/v6/pipeable-operators) + +And: + +> "pipeable" operators is the current and recommended way of using operators since RxJS 5.5. The main difference is that it's easier to make custom operators and that it's better treeshakable while not altering some global Observable object that could possible make collisions if two different parties wanted to create an operator of the same name. - [StackOverflow](https://stackoverflow.com/questions/48668701/what-is-pipe-for-in-rxjs) \ No newline at end of file From 50a75f3d2ce5c7a6ec1e4c3f0d711c13d59c71c5 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Tue, 4 Jun 2024 14:17:56 -0700 Subject: [PATCH 10/30] Simplified impl --- sketches/2024-05-30-common-frp/NOTES.md | 12 ++ .../2024-05-30-common-frp/src/common-frp.ts | 128 ++++++++++-------- .../src/example/basic.ts | 8 +- 3 files changed, 91 insertions(+), 57 deletions(-) diff --git a/sketches/2024-05-30-common-frp/NOTES.md b/sketches/2024-05-30-common-frp/NOTES.md index f1f664467..e1d27adae 100644 --- a/sketches/2024-05-30-common-frp/NOTES.md +++ b/sketches/2024-05-30-common-frp/NOTES.md @@ -25,6 +25,18 @@ Rough notes and references while designing. - Update sinks: get updated cell and computed state. Computed state is recomputed if dirty. - Subscribe with sinks +## Rough notes + +### Restricted operators + +- It may be worth offering operators that do not allow arbitrary Turing-complete function definitions. E.g. a restricted subset of data operators taken from SQL or Linq + - `select(keyPath: string)` - a restricted form of map + - `where(selector: formula)` - a restricted form of filter + - `groupBy()` + - `orderBy()` + - `union()`, `intersect()` - restricted forms of join/merge + - `count()`, `min()`, `max()`, `sum()`, `avg()`, `truncate()` - restricted forms of computation + ## Prior art ### Classical FRP diff --git a/sketches/2024-05-30-common-frp/src/common-frp.ts b/sketches/2024-05-30-common-frp/src/common-frp.ts index e0887ba44..5012bae9c 100644 --- a/sketches/2024-05-30-common-frp/src/common-frp.ts +++ b/sketches/2024-05-30-common-frp/src/common-frp.ts @@ -8,6 +8,11 @@ const debugLog = (tag: string, msg: string) => { } } +let _cidCounter = 0 + +/** Create a lifetime-unique client ID, based on incrementing a counter */ +const cid = () => `cid${_cidCounter++}` + export const batched = (perform: (value: T) => void) => { let isScheduled = false let state: T | undefined = undefined @@ -27,39 +32,43 @@ export const batched = (perform: (value: T) => void) => { export type Cancel = () => void -export type Subscriber = { +function* filterMap( + iterable: Iterable, + transform: (value: T) => U|undefined +): Generator { + for (const value of iterable) { + const result = transform(value) + if (result !== undefined) { + yield result + } + } +} + +export type Subject = { send: (value: T) => void } export type Sink = { - sink: (subscriber: Subscriber) => Cancel + sink: (subscriber: Subject) => Cancel } -/** - * Create one-to-many event broadcast channel for a list of subscribers. - * This is a low-level helper that publishes *synchronously* to all subscribers. - * We use this to implement higher-level abstractions like streams and cells, - * which dispatch using a shared transaction system. - */ -export const createPublisher = () => { - const subscribers = new Set>() - - /** Get subscribers */ - const subs = () => subscribers.values() - - const send = (value: T) => { - debugLog('publisher', `dispatching ${value} to ${subscribers.size} subscribers`) - for (const subscriber of subscribers) { - subscriber.send(value) - } +const pub = (subscribers: Set>, value: T) => { + debugLog('pub', `dispatching value ${value} to ${subscribers.size} subscribers`) + for (const subscriber of subscribers) { + subscriber.send(value) } +} - const sink = (subscriber: Subscriber): Cancel => { - subscribers.add(subscriber) - return () => subscribers.delete(subscriber) +const sub = ( + subscribers: Set>, + subscriber: Subject +) => { + debugLog('sub', 'subscribing') + subscribers.add(subscriber) + return () => { + debugLog('sub', 'canceling subscription') + subscribers.delete(subscriber) } - - return {send, sink, subs} } export const combineCancels = (cancels: Array): Cancel => () => { @@ -69,13 +78,17 @@ export const combineCancels = (cancels: Array): Cancel => () => { } export const createStream = () => { - const updatesPublisher = createPublisher() + const id = cid() + const downstreams = new Set>() + const send = batched((value: T) => { - debugLog('stream', `sending ${value}`) - updatesPublisher.send(value) + debugLog(`stream ${id}`, `sending ${value}`) + pub(downstreams, value) }) - const sink = updatesPublisher.sink - return {name, send, sink} + + const sink = (subscriber: Subject) => sub(downstreams, subscriber) + + return {send, sink} } const noOp = () => {} @@ -95,11 +108,15 @@ export const generateStream = ( export const mapStream = ( upstream: Sink, transform: (value: T) => U -) => generateStream((send: (value: U) => void) => { - return upstream.sink({ - send: value => send(transform(value)) +) => { + const transformed = createStream() + + const cancel = upstream.sink({ + send: value => transformed.send(transform(value)) }) -}) + + return {cancel, sink: transformed.sink} +} export const filterStream = ( upstream: Sink, @@ -122,13 +139,9 @@ export type Gettable = { export const sample = (container: Gettable) => container.get() -export type CellLike = { - get: () => T - sink(subscriber: Subscriber): Cancel -} - -export const createCell = (initial: T, name: string) => { - const publisher = createPublisher() +export const createCell = (initial: T) => { + const id = cid() + const downstreams = new Set>() let state = initial const get = () => state @@ -137,24 +150,30 @@ export const createCell = (initial: T, name: string) => { // Only notify downstream if state has changed value if (!isEqual(state, value)) { state = value - debugLog('cell', `updated ${state}`) - publisher.send(state) + debugLog(`cell ${id}`, `updated ${state}`) + pub(downstreams, state) } }) - const sink = (subscriber: Subscriber) => { + const sink = (subscriber: Subject) => { subscriber.send(state) - return publisher.sink(subscriber) + return sub(downstreams, subscriber) } - return {name, get, send, sink} + return {get, send, sink} +} + +export type CellLike = { + get: () => T + sink: (subscriber: Subject) => Cancel } export const createComputed = ( upstreams: Array>, compute: (...values: Array) => T ) => { - const publisher = createPublisher() + const id = cid() + const downstreams = new Set>() const recompute = (): T => compute(...upstreams.map(sample)) @@ -163,20 +182,20 @@ export const createComputed = ( const get = () => state const subject = { - send: batched(_value => { + send: batched(_ => { state = recompute() - debugLog('computed', `recomputed ${state}`) - publisher.send(state) + debugLog(`computed ${id}`, `recomputed ${state}`) + pub(downstreams, state) }) } const cancel = combineCancels(upstreams.map(cell => cell.sink(subject))) const sink = ( - subscriber: Subscriber + subscriber: Subject ) => { subscriber.send(state) - return publisher.sink(subscriber) + return sub(downstreams, subscriber) } return {get, sink, cancel} @@ -188,8 +207,11 @@ export const createComputed = ( * @param initial - the initial value for the cell * @returns cell */ -export const hold = (stream: Sink, initial: T, name: string) => { - const cell = createCell(initial, name) +export const hold = ( + stream: Sink, + initial: T +) => { + const cell = createCell(initial) const cancel = stream.sink(cell) return {get: cell.get, sink: cell.sink, cancel} } diff --git a/sketches/2024-05-30-common-frp/src/example/basic.ts b/sketches/2024-05-30-common-frp/src/example/basic.ts index 2fcbf741d..7209e7394 100644 --- a/sketches/2024-05-30-common-frp/src/example/basic.ts +++ b/sketches/2024-05-30-common-frp/src/example/basic.ts @@ -12,16 +12,16 @@ const xPos = mapStream(clicks, (event) => { return mouseEvent.clientX }) -const currentX = hold(xPos, 0, 'currentX') +const currentX = hold(xPos, 0) -const a = createCell(0, 'foo') +const a = createCell(0) setInterval(() => { a.send(a.get() + 1) }, 1000) -const b = createComputed([a, currentX], (a) => a + 1) -const c = createComputed([a, currentX], (a) => a + 2) +const b = createComputed([a, currentX], (a): number => a + 1) +const c = createComputed([a, currentX], (a): number => a + 2) const d = createComputed([b, c], (b, c) => b + c) // You should only see one log message per update From 257c2272613afcf5b58d526893dc30d370a76c84 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Tue, 4 Jun 2024 14:18:45 -0700 Subject: [PATCH 11/30] Remove dead code --- sketches/2024-05-30-common-frp/src/common-frp.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/sketches/2024-05-30-common-frp/src/common-frp.ts b/sketches/2024-05-30-common-frp/src/common-frp.ts index 5012bae9c..f26d29d5c 100644 --- a/sketches/2024-05-30-common-frp/src/common-frp.ts +++ b/sketches/2024-05-30-common-frp/src/common-frp.ts @@ -32,18 +32,6 @@ export const batched = (perform: (value: T) => void) => { export type Cancel = () => void -function* filterMap( - iterable: Iterable, - transform: (value: T) => U|undefined -): Generator { - for (const value of iterable) { - const result = transform(value) - if (result !== undefined) { - yield result - } - } -} - export type Subject = { send: (value: T) => void } From f9c57a38ebc257827d71609e6469f48eb2a28cc3 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 5 Jun 2024 10:45:53 -0700 Subject: [PATCH 12/30] First pass on transaction-based push-pull FRP - Two transaction phases, updates and reads - Updates sets state, marks computed dirty, reads recalculates computed (using call stack as an implicit topological sorting mechanism) TODO: write tests --- sketches/2024-05-30-common-frp/NOTES.md | 6 +- .../2024-05-30-common-frp/src/common-frp.ts | 308 +++++++++++------- .../src/example/basic.ts | 4 +- .../experimental/common-frp-transaction.ts | 304 ----------------- 4 files changed, 190 insertions(+), 432 deletions(-) delete mode 100644 sketches/2024-05-30-common-frp/src/experimental/common-frp-transaction.ts diff --git a/sketches/2024-05-30-common-frp/NOTES.md b/sketches/2024-05-30-common-frp/NOTES.md index e1d27adae..72f6b6271 100644 --- a/sketches/2024-05-30-common-frp/NOTES.md +++ b/sketches/2024-05-30-common-frp/NOTES.md @@ -86,7 +86,11 @@ Libraries: - [TC39 Signals Proposal](https://github.com/tc39/proposal-signals) - [SolidJS](https://www.solidjs.com/) - [Preact Signals](https://preactjs.com/guide/v10/signals/) -- [S.js](https://github.com/adamhaile/S) implements transactions and dynamic graph with single-transaction callback registration, eliminating listener memory leaks. +- [S.js](https://github.com/adamhaile/S) + - implements transactions and dynamic graph with single-transaction callback registration, eliminating listener memory leaks. +- [Arrow.js](https://www.arrow-js.com/docs/) + - Focuses on reactive objects, rather than values + - Key-path style indexing using Proxy - [Elm Signals 3.0.0](https://github.com/elm-lang/core/blob/3.0.0/src/Native/Signal.js). Deprecated, but the implementation can be found here. 1st order FRP. ### Observables diff --git a/sketches/2024-05-30-common-frp/src/common-frp.ts b/sketches/2024-05-30-common-frp/src/common-frp.ts index f26d29d5c..b54c6f14b 100644 --- a/sketches/2024-05-30-common-frp/src/common-frp.ts +++ b/sketches/2024-05-30-common-frp/src/common-frp.ts @@ -1,3 +1,7 @@ +/* +TODO: beginnings of a push-pull FRP system using transactions. +This will enable graph cycles. +*/ export const config = { debug: false } @@ -8,115 +12,159 @@ const debugLog = (tag: string, msg: string) => { } } -let _cidCounter = 0 +const createTransactionManager = () => { + const updates = new Map<(value: any) => void, any>() + const reads = new Set<() => void>() -/** Create a lifetime-unique client ID, based on incrementing a counter */ -const cid = () => `cid${_cidCounter++}` - -export const batched = (perform: (value: T) => void) => { let isScheduled = false - let state: T | undefined = undefined - const schedule = (value: T) => { - state = value - if (!isScheduled) { - isScheduled = true - queueMicrotask(() => { - debugLog('batched', `performing ${state}`) - perform(state!) - isScheduled = false - }) + + const schedule = () => { + if (isScheduled) { + return } + debugLog('TransactionManager.schedule', `transaction scheduled`) + isScheduled = true + queueMicrotask(transact) } - return schedule -} - -export type Cancel = () => void -export type Subject = { - send: (value: T) => void -} + const transact = () => { + debugLog('TransactionManager.transact', 'transaction start') + // First perform all cell state changes. + // - Update cell state + // - Mark computed dirty + debugLog('TransactionManager.transact', `transact updates`) + for (const [job, value] of updates) { + job(value) + } + updates.clear() + // Then perform all cell state reads + // - Read cell state + // - Recompute computed cells and mark clean + debugLog('TransactionManager.transact', `transact reads`) + for (const job of reads) { + job() + } + reads.clear() + isScheduled = false + debugLog('TransactionManager.transact', 'transaction end') + } -export type Sink = { - sink: (subscriber: Subject) => Cancel -} + const withUpdates = (job: (value: T) => void, value: T) => { + debugLog('TransactionManager.withUpdates', `queue job with value ${value}`) + updates.set(job, value) + schedule() + } -const pub = (subscribers: Set>, value: T) => { - debugLog('pub', `dispatching value ${value} to ${subscribers.size} subscribers`) - for (const subscriber of subscribers) { - subscriber.send(value) + const withReads =(job: () => void) => { + debugLog('TransactionManager.withReads', `queue job`) + reads.add(job) + schedule() } + + return {withUpdates, withReads} } -const sub = ( - subscribers: Set>, - subscriber: Subject -) => { - debugLog('sub', 'subscribing') - subscribers.add(subscriber) - return () => { - debugLog('sub', 'canceling subscription') - subscribers.delete(subscriber) +const {withUpdates, withReads} = createTransactionManager() + +export type Unsubscribe = () => void + +export type Subscriber = (value: T) => void + +/** Low-level pub-sub channel used under the hood by cells and sinks. */ +const createPublisher = () => { + const subscribers = new Set>() + + const pub = (value: T) => { + debugLog('pub', `dispatching ${value} to ${subscribers.size} subscribers`) + for (const subscriber of subscribers) { + subscriber(value) + } + } + + /** + * Subscribe to this publisher + * @param subscriber the function to call when a new value is published + * @returns Unsubscribe function + */ + const sub = (subscriber: Subscriber): Unsubscribe => { + debugLog('sub', 'subscribing') + subscribers.add(subscriber) + return () => { + debugLog('sub', 'canceling subscription') + subscribers.delete(subscriber) + } } + + return {pub, sub} } -export const combineCancels = (cancels: Array): Cancel => () => { - for (const cancel of cancels) { +/** Combine multiple unsubscribe functions into a single unsubscribe function */ +export const combineUnsubscribes = ( + unsubscribes: Array +): Unsubscribe => () => { + for (const cancel of unsubscribes) { cancel() } } -export const createStream = () => { - const id = cid() - const downstreams = new Set>() +/** Symbol for updates subscribe method */ +const __updates__: unique symbol = Symbol('updates') - const send = batched((value: T) => { - debugLog(`stream ${id}`, `sending ${value}`) - pub(downstreams, value) - }) +export type UpdatesProvider = { + [__updates__]: (subscriber: Subscriber) => Unsubscribe +} - const sink = (subscriber: Subject) => sub(downstreams, subscriber) +export type SinkProvider = { + sink: (subscriber: Subscriber) => Unsubscribe +} - return {send, sink} +export type ReadStream = SinkProvider & UpdatesProvider & { + unsubscribe?: Unsubscribe } -const noOp = () => {} +export const createStream = () => { + const updates = createPublisher() + + const performUpdate = (value: T) => { + debugLog(`stream`, `value: ${value}`) + updates.pub(value) + } + + const send = (value: T) => withUpdates(performUpdate, value) + + const sink = (subscriber: Subscriber) => updates.sub( + (value: T) => withReads(() => subscriber(value)) + ) + + return {send, [__updates__]: updates.sub, sink} +} -/** - * Generate a stream using a callback. - * Returns a read-only stream. - */ export const generateStream = ( - produce: (send: (value: T) => void) => Cancel|void -) => { - const {send, sink} = createStream() - const cancel = produce(send) ?? noOp - return {cancel, sink} + generate: (send: (value: T) => void) => Unsubscribe|undefined +): ReadStream => { + const {send, [__updates__]: updates, sink} = createStream() + const unsubscribe = generate(send) + return {[__updates__]: updates, sink, unsubscribe} } export const mapStream = ( - upstream: Sink, + stream: ReadStream, transform: (value: T) => U -) => { - const transformed = createStream() - - const cancel = upstream.sink({ - send: value => transformed.send(transform(value)) - }) - - return {cancel, sink: transformed.sink} -} +) => generateStream((send) => { + const subscribe = (value: T) => send(transform(value)) + return stream[__updates__](subscribe) +}) -export const filterStream = ( - upstream: Sink, - predicate: (value: T) => boolean -) => generateStream(send => { - return upstream.sink({ - send: value => { - if (predicate(value)) { - send(value) - } +export const filterStream = ( + stream: ReadStream, + predicate: (value: T) => U +) => generateStream((send) => { + const subscribe = (value: T) => { + if (predicate(value)) { + send(value) } - }) + } + return stream[__updates__](subscribe) }) const isEqual = Object.is @@ -127,79 +175,91 @@ export type Gettable = { export const sample = (container: Gettable) => container.get() +export type CellLike = { + get(): T + [__updates__]: (subscriber: Subscriber) => Unsubscribe + sink: (subscriber: Subscriber) => Unsubscribe +} + export const createCell = (initial: T) => { - const id = cid() - const downstreams = new Set>() + const updates = createPublisher() + let state = initial const get = () => state - const send = batched((value: T) => { - // Only notify downstream if state has changed value + const performUpdate = (value: T) => { + // Only perform update if state has actually changed if (!isEqual(state, value)) { + debugLog(`cell`, `value: ${state}`) state = value - debugLog(`cell ${id}`, `updated ${state}`) - pub(downstreams, state) + updates.pub() } - }) - - const sink = (subscriber: Subject) => { - subscriber.send(state) - return sub(downstreams, subscriber) } - return {get, send, sink} -} + const send = (value: T) => withUpdates(performUpdate, value) -export type CellLike = { - get: () => T - sink: (subscriber: Subject) => Cancel + const sink = (subscriber: Subscriber) => { + const forward = () => subscriber(get()) + return updates.sub(() => withReads(forward)) + } + + return { + get, + send, + [__updates__]: updates.sub, + sink + } } export const createComputed = ( upstreams: Array>, - compute: (...values: Array) => T + calc: (...values: Array) => T ) => { - const id = cid() - const downstreams = new Set>() + const updates = createPublisher() - const recompute = (): T => compute(...upstreams.map(sample)) + const recompute = (): T => calc(...upstreams.map(sample)) + let isDirty = false let state = recompute() - const get = () => state + const performUpdate = () => { + debugLog(`computed`, `mark dirty`) + isDirty = true + updates.pub() + } + + const unsubscribe = combineUnsubscribes( + upstreams.map(cell => cell[__updates__](performUpdate)) + ) - const subject = { - send: batched(_ => { + const get = () => { + if (isDirty) { state = recompute() - debugLog(`computed ${id}`, `recomputed ${state}`) - pub(downstreams, state) - }) + debugLog(`computed`, `recomputed state: ${state}`) + isDirty = false + } + return state } - const cancel = combineCancels(upstreams.map(cell => cell.sink(subject))) - - const sink = ( - subscriber: Subject - ) => { - subscriber.send(state) - return sub(downstreams, subscriber) + const sink = (subscriber: Subscriber) => { + const forward = () => subscriber(get()) + return updates.sub(() => withReads(forward)) } - return {get, sink, cancel} + return { + get, + [__updates__]: updates.sub, + sink, + unsubscribe: unsubscribe + } } /** * "Hold" the latest value from a stream in a cell - * @param stream - the stream to update cell - * @param initial - the initial value for the cell - * @returns cell */ -export const hold = ( - stream: Sink, - initial: T -) => { - const cell = createCell(initial) - const cancel = stream.sink(cell) - return {get: cell.get, sink: cell.sink, cancel} -} +export const hold = (stream: ReadStream, initial: T) => { + const {get, [__updates__]: updates, sink, send} = createCell(initial) + const unsubscribe = stream.sink((value: T) => send(value)) + return {get, [__updates__]: updates, sink, unsubscribe} +} \ No newline at end of file diff --git a/sketches/2024-05-30-common-frp/src/example/basic.ts b/sketches/2024-05-30-common-frp/src/example/basic.ts index 7209e7394..11e9bad4b 100644 --- a/sketches/2024-05-30-common-frp/src/example/basic.ts +++ b/sketches/2024-05-30-common-frp/src/example/basic.ts @@ -25,6 +25,4 @@ const c = createComputed([a, currentX], (a): number => a + 2) const d = createComputed([b, c], (b, c) => b + c) // You should only see one log message per update -d.sink({ - send: (x) => console.log(x) -}) \ No newline at end of file +d.sink(x => console.log(x)) \ No newline at end of file diff --git a/sketches/2024-05-30-common-frp/src/experimental/common-frp-transaction.ts b/sketches/2024-05-30-common-frp/src/experimental/common-frp-transaction.ts deleted file mode 100644 index 91dbfb821..000000000 --- a/sketches/2024-05-30-common-frp/src/experimental/common-frp-transaction.ts +++ /dev/null @@ -1,304 +0,0 @@ -/* -TODO: beginnings of a push-pull FRP system using transactions. -This will enable graph cycles. -*/ -export const config = { - debug: false -} - -const debugLog = (tag: string, msg: string) => { - if (config.debug) { - console.debug(`[${tag}] ${msg}`) - } -} - -export const chooseLeft = (left: T, _right: T) => left - -type TransactionQueue = { - name: string, - transact: () => void - withTransaction: ( - perform: (value: T) => void, - value: T, - choose: (left: T, right: T) => T - ) => void -} - -const createTransactionQueue = (name: string): TransactionQueue => { - const queue = new Map<(value: any) => void, any>() - - const transact = () => { - debugLog(name, `transacting ${queue.size} jobs`) - for (const [perform, value] of queue) { - perform(value) - } - queue.clear() - } - - /** - * Queue a job to perform during the next transaction, - * along with a value to pass to the job - * @param perform - The function to perform (used as the unique key in - * the queue) - * @param value - The value to pass to the perform function - * @param choose - A function to choose between two values if more than one - * has been set during the transaction (default is chooseLeft) - */ - const withTransaction = ( - perform: (value: T) => void, - value: T, - choose: (left: T, right: T) => T = chooseLeft - ) => { - const left = queue.get(perform) - queue.set(perform, left !== undefined ? choose(left, value) : value) - } - - return {name, transact, withTransaction} -} - -const createTransactionManager = () => { - const streams = createTransactionQueue('streams') - const cells = createTransactionQueue('cells') - const computed = createTransactionQueue('computed') - const sinks = createTransactionQueue('sinks') - - let isScheduled = false - - const schedule = () => { - if (isScheduled) { - return - } - debugLog('TransactionManager', `transaction scheduled`) - isScheduled = true - queueMicrotask(transact) - } - - const transact = () => { - debugLog('TransactionManager', 'transaction start') - // First perform all event stream updates - streams.transact() - // Then perform all cell state updates - cells.transact() - // Finally, update sinks - sinks.transact() - isScheduled = false - debugLog('TransactionManager', 'transaction end') - } - - const withPhase = ( - queue: TransactionQueue - ) => ( - perform: (value: T) => void, - value: T, - choose: (left: T, right: T) => T = chooseLeft - ) => { - debugLog(queue.name, 'queue job') - queue.withTransaction(perform, value, choose) - schedule() - } - - const withStreams = withPhase(streams) - const withCells = withPhase(cells) - const withComputed = withPhase(computed) - const withSinks = withPhase(sinks) - - return {withCells, withStreams, withComputed, withSinks} -} - -const { - withCells, - withStreams, - withComputed, - withSinks -} = createTransactionManager() - -export type Cancel = () => void - -export type Subscriber = { - send: (value: T) => void -} - -export type Sink = { - sink: (subscriber: Subscriber) => Cancel -} - -/** - * Create one-to-many event broadcast channel for a list of subscribers. - * This is a low-level helper that publishes *synchronously* to all subscribers. - * We use this to implement higher-level abstractions like streams and cells, - * which dispatch using a shared transaction system. - */ -export const createPublisher = () => { - const subscribers = new Set>() - - /** Get subscribers */ - const subs = () => subscribers.values() - - const send = (value: T) => { - for (const subscriber of subscribers) { - subscriber.send(value) - } - } - - const sink = (subscriber: Subscriber): Cancel => { - subscribers.add(subscriber) - return () => subscribers.delete(subscriber) - } - - return {send, sink, subs} -} - -export const combineCancels = (cancels: Array): Cancel => () => { - for (const cancel of cancels) { - cancel() - } -} - -export const createStream = (choose = chooseLeft) => { - const updatesPublisher = createPublisher() - const send = (value: T) => withStreams(updatesPublisher.send, value, choose) - const sink = (subscriber: Subscriber) => updatesPublisher.sink(subscriber) - return {send, sink} -} - -const noOp = () => {} - -/** - * Generate a stream using a callback. - * Returns a read-only stream. - */ -export const generateStream = ( - produce: (send: (value: T) => void) => Cancel|void -) => { - const {send, sink} = createStream() - const cancel = produce(send) ?? noOp - return {cancel, sink} -} - -export const mapStream = ( - upstream: Sink, - transform: (value: T) => U -) => generateStream((send: (value: U) => void) => { - return upstream.sink({ - send: value => send(transform(value)) - }) -}) - -export const filterStream = ( - upstream: Sink, - predicate: (value: T) => boolean -) => generateStream(send => { - return upstream.sink({ - send: value => { - if (predicate(value)) { - send(value) - } - } - }) -}) - -const isEqual = Object.is - -export type Gettable = { - get(): T -} - -export const sample = (container: Gettable) => container.get() - -export type CellLike = { - get(): T - onChange(subscriber: Subscriber): Cancel -} - -export const createCell = (initial: T, name: string) => { - const dirtyPublisher = createPublisher() - let state = initial - - const get = () => state - const getName = () => name - - const setState = (value: T) => { - // Only notify downstream if state has changed value - if (!isEqual(state, value)) { - state = value - dirtyPublisher.send() - } - } - - const send = (value: T) => { - withCells(setState, value) - } - - const onChange = (subscriber: Subscriber) => { - subscriber.send() - return dirtyPublisher.sink(subscriber) - } - - return {get, name: getName, send, onChange} -} - -export const createComputed = ( - upstreams: Array>, - calc: (...values: Array) => T -) => { - const dirtyPublisher = createPublisher() - - const recompute = (): T => calc(...upstreams.map(sample)) - - let isDirty = false - let state = recompute() - - // TODO need to actually dispatch to sinks - // Think about this... when does a computed cell recalculate? - const markDirty = () => { - isDirty = true - dirtyPublisher.send() - } - - const subject = { - send: markDirty - } - - const cancel = combineCancels(upstreams.map(cell => cell.onChange(subject))) - - const get = () => { - if (isDirty) { - state = recompute() - isDirty = false - } - return state - } - - const onChange = ( - subscriber: Subscriber - ) => { - subscriber.send() - dirtyPublisher.sink(subscriber) - } - - return {get, onChange, cancel} -} - -/** - * "Hold" the latest value from a stream in a cell - * @param stream - the stream to update cell - * @param initial - the initial value for the cell - * @returns cell - */ -export const hold = (stream: Sink, initial: T) => { - const {get, onChange, send} = createCell(initial, 'hold') - const cancel = stream.sink({ - send: value => send(value) - }) - return {get, onChange, cancel} -} - -export const createEffect = ( - cell: CellLike, - receive: (value: T) => void -) => { - const send = (cell: CellLike) => receive(cell.get()) - cell.onChange({ - send: () => withSinks(send, cell) - }) -} \ No newline at end of file From 07fae2cd55bc98389da0052a680fac7c16639024 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 5 Jun 2024 11:04:58 -0700 Subject: [PATCH 13/30] Update type sig of generateStream --- sketches/2024-05-30-common-frp/src/common-frp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sketches/2024-05-30-common-frp/src/common-frp.ts b/sketches/2024-05-30-common-frp/src/common-frp.ts index b54c6f14b..0dd13430f 100644 --- a/sketches/2024-05-30-common-frp/src/common-frp.ts +++ b/sketches/2024-05-30-common-frp/src/common-frp.ts @@ -150,7 +150,7 @@ export const generateStream = ( export const mapStream = ( stream: ReadStream, transform: (value: T) => U -) => generateStream((send) => { +) => generateStream((send: (value: U) => void) => { const subscribe = (value: T) => send(transform(value)) return stream[__updates__](subscribe) }) From 0d1e2e52278de2308cb4c957c38ab53d62c564f0 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 5 Jun 2024 11:26:01 -0700 Subject: [PATCH 14/30] Add typescript overloads for createComputed --- .../2024-05-30-common-frp/src/common-frp.ts | 74 +++++++++++++++++-- .../src/example/basic.ts | 8 +- 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/sketches/2024-05-30-common-frp/src/common-frp.ts b/sketches/2024-05-30-common-frp/src/common-frp.ts index 0dd13430f..82e6d0d7b 100644 --- a/sketches/2024-05-30-common-frp/src/common-frp.ts +++ b/sketches/2024-05-30-common-frp/src/common-frp.ts @@ -175,9 +175,9 @@ export type Gettable = { export const sample = (container: Gettable) => container.get() -export type CellLike = { +export type ReadCell = { get(): T - [__updates__]: (subscriber: Subscriber) => Unsubscribe + [__updates__]: (subscriber: Subscriber) => Unsubscribe sink: (subscriber: Subscriber) => Unsubscribe } @@ -212,13 +212,73 @@ export const createCell = (initial: T) => { } } -export const createComputed = ( - upstreams: Array>, - calc: (...values: Array) => T +export type createComputed = { + ( + upstreams: [ReadCell, ReadCell], + compute: (a: A, b: B) => Z + ): ReadCell + ( + upstreams: [ReadCell, ReadCell, ReadCell], + compute: (a: A, b: B, c: C) => Z + ): ReadCell + ( + upstreams: [ReadCell, ReadCell, ReadCell, ReadCell], + compute: (a: A, b: B, c: C, d: D) => Z + ): ReadCell + ( + upstreams: [ + ReadCell, + ReadCell, + ReadCell, + ReadCell, + ReadCell], + compute: (a: A, b: B, c: C, d: D, e: E) => Z + ): ReadCell + ( + upstreams: [ + ReadCell, + ReadCell, + ReadCell, + ReadCell, + ReadCell, + ReadCell + ], + compute: (a: A, b: B, c: C, d: D, e: E, f: F) => Z + ): ReadCell + ( + upstreams: [ + ReadCell, + ReadCell, + ReadCell, + ReadCell, + ReadCell, + ReadCell, + ReadCell + ], + compute: (a: A, b: B, c: C, d: D, e: E, f: F, g: G) => Z + ): ReadCell + ( + upstreams: [ + ReadCell, + ReadCell, + ReadCell, + ReadCell, + ReadCell, + ReadCell, + ReadCell, + ReadCell + ], + compute: (a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H) => Z + ): ReadCell +} + +export const createComputed: createComputed = ( + upstreams: Array>, + compute: (...values: Array) => any ) => { const updates = createPublisher() - const recompute = (): T => calc(...upstreams.map(sample)) + const recompute = () => compute(...upstreams.map(sample)) let isDirty = false let state = recompute() @@ -242,7 +302,7 @@ export const createComputed = ( return state } - const sink = (subscriber: Subscriber) => { + const sink = (subscriber: Subscriber) => { const forward = () => subscriber(get()) return updates.sub(() => withReads(forward)) } diff --git a/sketches/2024-05-30-common-frp/src/example/basic.ts b/sketches/2024-05-30-common-frp/src/example/basic.ts index 11e9bad4b..be12adef5 100644 --- a/sketches/2024-05-30-common-frp/src/example/basic.ts +++ b/sketches/2024-05-30-common-frp/src/example/basic.ts @@ -16,12 +16,8 @@ const currentX = hold(xPos, 0) const a = createCell(0) -setInterval(() => { - a.send(a.get() + 1) -}, 1000) - -const b = createComputed([a, currentX], (a): number => a + 1) -const c = createComputed([a, currentX], (a): number => a + 2) +const b = createComputed([a, currentX], (a, b) => a + b + 1) +const c = createComputed([a, currentX], (a, b) => a + b + 2) const d = createComputed([b, c], (b, c) => b + c) // You should only see one log message per update From 3b0ae76506c0af33343758f4291b880792d9405c Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 5 Jun 2024 13:42:19 -0700 Subject: [PATCH 15/30] Better types --- .../2024-05-30-common-frp/src/common-frp.ts | 68 ++++++++++++------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/sketches/2024-05-30-common-frp/src/common-frp.ts b/sketches/2024-05-30-common-frp/src/common-frp.ts index 82e6d0d7b..197e5e548 100644 --- a/sketches/2024-05-30-common-frp/src/common-frp.ts +++ b/sketches/2024-05-30-common-frp/src/common-frp.ts @@ -108,21 +108,29 @@ export const combineUnsubscribes = ( } /** Symbol for updates subscribe method */ -const __updates__: unique symbol = Symbol('updates') +const __updates__ = Symbol('updates') -export type UpdatesProvider = { +export type Updates = { [__updates__]: (subscriber: Subscriber) => Unsubscribe } -export type SinkProvider = { +export type Sink = { sink: (subscriber: Subscriber) => Unsubscribe } -export type ReadStream = SinkProvider & UpdatesProvider & { +export type Subject = { + send: (value: T) => void +} + +export type Unsubscribable = { unsubscribe?: Unsubscribe } -export const createStream = () => { +export type ReadStream = Sink & Updates & Unsubscribable + +export type ReadWriteStream = Sink & Updates & Subject + +export const createStream = (): ReadWriteStream => { const updates = createPublisher() const performUpdate = (value: T) => { @@ -155,10 +163,10 @@ export const mapStream = ( return stream[__updates__](subscribe) }) -export const filterStream = ( +export const filterStream = ( stream: ReadStream, - predicate: (value: T) => U -) => generateStream((send) => { + predicate: (value: T) => boolean +) => generateStream(send => { const subscribe = (value: T) => { if (predicate(value)) { send(value) @@ -173,13 +181,10 @@ export type Gettable = { get(): T } -export const sample = (container: Gettable) => container.get() +const sample = (container: Gettable) => container.get() -export type ReadCell = { - get(): T - [__updates__]: (subscriber: Subscriber) => Unsubscribe - sink: (subscriber: Subscriber) => Unsubscribe -} +export type ReadCell = Gettable & Updates & Sink & Unsubscribable +export type ReadWriteCell = Gettable & Updates & Sink export const createCell = (initial: T) => { const updates = createPublisher() @@ -200,8 +205,9 @@ export const createCell = (initial: T) => { const send = (value: T) => withUpdates(performUpdate, value) const sink = (subscriber: Subscriber) => { - const forward = () => subscriber(get()) - return updates.sub(() => withReads(forward)) + const job = () => subscriber(get()) + job() + return updates.sub(() => withReads(job)) } return { @@ -275,7 +281,7 @@ export type createComputed = { export const createComputed: createComputed = ( upstreams: Array>, compute: (...values: Array) => any -) => { +): ReadCell => { const updates = createPublisher() const recompute = () => compute(...upstreams.map(sample)) @@ -303,8 +309,9 @@ export const createComputed: createComputed = ( } const sink = (subscriber: Subscriber) => { - const forward = () => subscriber(get()) - return updates.sub(() => withReads(forward)) + const job = () => subscriber(get()) + job() + return updates.sub(() => withReads(job)) } return { @@ -316,10 +323,25 @@ export const createComputed: createComputed = ( } /** - * "Hold" the latest value from a stream in a cell + * Scan a stream producing a cell that contains the reductions of each step + * of the reduce operation. */ -export const hold = (stream: ReadStream, initial: T) => { +export const scan = ( + stream: ReadStream, + step: (state: U, value: T) => U, + initial: U +): ReadCell => { const {get, [__updates__]: updates, sink, send} = createCell(initial) - const unsubscribe = stream.sink((value: T) => send(value)) + const unsubscribe = stream.sink((value: T) => { + send(step(get(), value)) + }) return {get, [__updates__]: updates, sink, unsubscribe} -} \ No newline at end of file +} + +/** + * Hold the latest value from a stream in a cell. + */ +export const hold = ( + stream: ReadStream, + initial: T +) => scan(stream, (_, value) => value, initial) \ No newline at end of file From d0541d96c9b2f01755eb697c835363aeab2e5a5f Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 5 Jun 2024 13:57:33 -0700 Subject: [PATCH 16/30] Add separate stream phase to transaction We want streams to run before cell state updates so that they can only sample the state of the graph before it updates. ...also fix bug with scan where it was one step behind. --- .../2024-05-30-common-frp/src/common-frp.ts | 60 +++++++++++-------- .../src/example/basic.ts | 21 ++----- 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/sketches/2024-05-30-common-frp/src/common-frp.ts b/sketches/2024-05-30-common-frp/src/common-frp.ts index 197e5e548..9fe439f09 100644 --- a/sketches/2024-05-30-common-frp/src/common-frp.ts +++ b/sketches/2024-05-30-common-frp/src/common-frp.ts @@ -13,8 +13,9 @@ const debugLog = (tag: string, msg: string) => { } const createTransactionManager = () => { - const updates = new Map<(value: any) => void, any>() - const reads = new Set<() => void>() + const streams = new Map<(value: any) => void, any>() + const cells = new Map<(value: any) => void, any>() + const sinks = new Set<() => void>() let isScheduled = false @@ -29,42 +30,53 @@ const createTransactionManager = () => { const transact = () => { debugLog('TransactionManager.transact', 'transaction start') + // First perform all stream updates. + debugLog('TransactionManager.transact', `transact streams`) + for (const [job, value] of streams) { + job(value) + } // First perform all cell state changes. // - Update cell state // - Mark computed dirty - debugLog('TransactionManager.transact', `transact updates`) - for (const [job, value] of updates) { + debugLog('TransactionManager.transact', `transact cells`) + for (const [job, value] of cells) { job(value) } - updates.clear() + cells.clear() // Then perform all cell state reads // - Read cell state // - Recompute computed cells and mark clean - debugLog('TransactionManager.transact', `transact reads`) - for (const job of reads) { + debugLog('TransactionManager.transact', `transact sinks`) + for (const job of sinks) { job() } - reads.clear() + sinks.clear() isScheduled = false debugLog('TransactionManager.transact', 'transaction end') } - const withUpdates = (job: (value: T) => void, value: T) => { - debugLog('TransactionManager.withUpdates', `queue job with value ${value}`) - updates.set(job, value) + const withStreams = (job: (value: T) => void, value: T) => { + debugLog('TransactionManager.withStreams', `queue job with value ${value}`) + streams.set(job, value) + schedule() + } + + const withCells = (job: (value: T) => void, value: T) => { + debugLog('TransactionManager.withCells', `queue job with value ${value}`) + cells.set(job, value) schedule() } - const withReads =(job: () => void) => { - debugLog('TransactionManager.withReads', `queue job`) - reads.add(job) + const withSinks =(job: () => void) => { + debugLog('TransactionManager.withSinks', `queue job`) + sinks.add(job) schedule() } - return {withUpdates, withReads} + return {withStreams, withCells, withSinks} } -const {withUpdates, withReads} = createTransactionManager() +const {withStreams, withCells, withSinks} = createTransactionManager() export type Unsubscribe = () => void @@ -133,15 +145,15 @@ export type ReadWriteStream = Sink & Updates & Subject export const createStream = (): ReadWriteStream => { const updates = createPublisher() - const performUpdate = (value: T) => { + const perform = (value: T) => { debugLog(`stream`, `value: ${value}`) updates.pub(value) } - const send = (value: T) => withUpdates(performUpdate, value) + const send = (value: T) => withStreams(perform, value) const sink = (subscriber: Subscriber) => updates.sub( - (value: T) => withReads(() => subscriber(value)) + (value: T) => withSinks(() => subscriber(value)) ) return {send, [__updates__]: updates.sub, sink} @@ -196,18 +208,18 @@ export const createCell = (initial: T) => { const performUpdate = (value: T) => { // Only perform update if state has actually changed if (!isEqual(state, value)) { - debugLog(`cell`, `value: ${state}`) state = value + debugLog(`cell`, `value: ${state}`) updates.pub() } } - const send = (value: T) => withUpdates(performUpdate, value) + const send = (value: T) => withCells(performUpdate, value) const sink = (subscriber: Subscriber) => { const job = () => subscriber(get()) job() - return updates.sub(() => withReads(job)) + return updates.sub(() => withSinks(job)) } return { @@ -311,7 +323,7 @@ export const createComputed: createComputed = ( const sink = (subscriber: Subscriber) => { const job = () => subscriber(get()) job() - return updates.sub(() => withReads(job)) + return updates.sub(() => withSinks(job)) } return { @@ -332,7 +344,7 @@ export const scan = ( initial: U ): ReadCell => { const {get, [__updates__]: updates, sink, send} = createCell(initial) - const unsubscribe = stream.sink((value: T) => { + const unsubscribe = stream[__updates__]((value: T) => { send(step(get(), value)) }) return {get, [__updates__]: updates, sink, unsubscribe} diff --git a/sketches/2024-05-30-common-frp/src/example/basic.ts b/sketches/2024-05-30-common-frp/src/example/basic.ts index be12adef5..43cf04576 100644 --- a/sketches/2024-05-30-common-frp/src/example/basic.ts +++ b/sketches/2024-05-30-common-frp/src/example/basic.ts @@ -1,4 +1,4 @@ -import {createCell, createComputed, hold, mapStream, config} from '../common-frp' +import {scan, config} from '../common-frp' import { events } from '../dom' config.debug = true @@ -7,18 +7,9 @@ const button = document.getElementById('button')! const clicks = events(button, 'click') -const xPos = mapStream(clicks, (event) => { - const mouseEvent = event as MouseEvent - return mouseEvent.clientX -}) +const clickCount = scan(clicks, (state, _) => state + 1, 0) -const currentX = hold(xPos, 0) - -const a = createCell(0) - -const b = createComputed([a, currentX], (a, b) => a + b + 1) -const c = createComputed([a, currentX], (a, b) => a + b + 2) -const d = createComputed([b, c], (b, c) => b + c) - -// You should only see one log message per update -d.sink(x => console.log(x)) \ No newline at end of file +clickCount.sink(x => { + button.textContent = `Clicks: ${x}` + console.log(x) +}) \ No newline at end of file From 32e37997139dcad128605ec00fdbe2e08904335d Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 5 Jun 2024 14:21:16 -0700 Subject: [PATCH 17/30] Clear streams after commit --- sketches/2024-05-30-common-frp/src/common-frp.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/sketches/2024-05-30-common-frp/src/common-frp.ts b/sketches/2024-05-30-common-frp/src/common-frp.ts index 9fe439f09..b17269438 100644 --- a/sketches/2024-05-30-common-frp/src/common-frp.ts +++ b/sketches/2024-05-30-common-frp/src/common-frp.ts @@ -35,6 +35,7 @@ const createTransactionManager = () => { for (const [job, value] of streams) { job(value) } + streams.clear() // First perform all cell state changes. // - Update cell state // - Mark computed dirty From 897e5f1e0c22bc4f521adf38d608d85956dd74e1 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 5 Jun 2024 17:07:33 -0700 Subject: [PATCH 18/30] Update notes --- sketches/2024-05-30-common-frp/NOTES.md | 38 +++++++++++++++++++------ 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/sketches/2024-05-30-common-frp/NOTES.md b/sketches/2024-05-30-common-frp/NOTES.md index 72f6b6271..da8233b65 100644 --- a/sketches/2024-05-30-common-frp/NOTES.md +++ b/sketches/2024-05-30-common-frp/NOTES.md @@ -1,8 +1,34 @@ # Notes -Rough notes and references while designing. +Devlog, reverse chronological order. -## Design +Contains rough notes and references while designing. + +## 2024-06-05 + +Proposed semantics after discussing with Berni. + +- Cells + - Discrete + - Use transactions to synchronize + - Last-write-wins at input boundary (e.g. too many clicks results in last click winning). +- Streams + - Discrete + - Uses separate transaction system to ensure one event is always one event + - Buffered semantics. (e.g. too many clicks results in more transactions being added to queue) + +Implications: + +- Streams and events do not happen during shared moments +- Streams may NEVER sample cell state + - Since they aren't synchronized, sampling might mean seeing the graph in an inconsistent state. + - Streams may only get values from upstream streams + - However, you may produce a stream from cell changes +- Streams are essentially "async" computation, while cells are "sync" + +## 2024-05-30 + +### Design - Discrete classical FRP - Streams @@ -16,7 +42,7 @@ Rough notes and references while designing. - Computed Cells - Reactive computed states, derived from cells and other computed cells. -## Implementation +### Implementation - Transaction - Update streams: Update streams: @@ -25,8 +51,6 @@ Rough notes and references while designing. - Update sinks: get updated cell and computed state. Computed state is recomputed if dirty. - Subscribe with sinks -## Rough notes - ### Restricted operators - It may be worth offering operators that do not allow arbitrary Turing-complete function definitions. E.g. a restricted subset of data operators taken from SQL or Linq @@ -37,8 +61,6 @@ Rough notes and references while designing. - `union()`, `intersect()` - restricted forms of join/merge - `count()`, `min()`, `max()`, `sum()`, `avg()`, `truncate()` - restricted forms of computation -## Prior art - ### Classical FRP Qualities: @@ -107,7 +129,7 @@ Libraries: - [TC39 Observable Proposal](https://github.com/tc39/proposal-observable) - [Apple Combine](https://developer.apple.com/documentation/combine/) -## Concepts +### Concepts - Paper: [Push-pull FRP](http://conal.net/papers/push-pull-frp/push-pull-frp.pdf), Conal Elliott - Book: [Functional Reactive Programming](https://www.manning.com/books/functional-reactive-programming), Manning 2016 From 7403aece51d01c80950f7ee419d8cc7a5dd2374a Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 5 Jun 2024 21:52:30 -0700 Subject: [PATCH 19/30] Add note --- sketches/2024-05-30-common-frp/NOTES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sketches/2024-05-30-common-frp/NOTES.md b/sketches/2024-05-30-common-frp/NOTES.md index da8233b65..98276c0a5 100644 --- a/sketches/2024-05-30-common-frp/NOTES.md +++ b/sketches/2024-05-30-common-frp/NOTES.md @@ -89,6 +89,10 @@ Qualities: > > (Functional Reactive Programming, 2.6.1, Manning) +Talks: + +- [A More Elegant Specification for Functional Reactive Programming, Conal Elliott](https://www.youtube.com/watch?v=9vMFHLHq7Y0) + Libraries: - [Sodium FRP](https://github.com/SodiumFRP) From ced4cf55de47ffcd9d21af6b31aae14aa6d3ddcb Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 5 Jun 2024 22:37:37 -0700 Subject: [PATCH 20/30] Add notes --- sketches/2024-05-30-common-frp/NOTES.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sketches/2024-05-30-common-frp/NOTES.md b/sketches/2024-05-30-common-frp/NOTES.md index 98276c0a5..086a034c8 100644 --- a/sketches/2024-05-30-common-frp/NOTES.md +++ b/sketches/2024-05-30-common-frp/NOTES.md @@ -26,6 +26,13 @@ Implications: - However, you may produce a stream from cell changes - Streams are essentially "async" computation, while cells are "sync" +--- + +> Really quickly: reactive-banana is definitely pull-based not push-pull. reactive is push-pull. Yampa and netwire are arrowized. There are FRPs which allow "accumulating values" but don't allow "switching", FRPs which allow "switching" but not "accumulating values". Both of those are "simple" FRP. Arrowized FRP allows switching and accumulating and uses arrows to control the danger of combining those features. Monadic FRP like reactive-banana, sodium, and elerea use other careful mechanisms to ensure that switching and accumulating don't interact too much. – [J. Abrahamson Oct 2, 2014 at 20:04](https://stackoverflow.com/questions/26164135/how-fundamentally-different-are-push-pull-and-arrowized-frp#comment41026849_26164135) + +> Arrowized FRP also has the neat feature that signals are always stated in context of their inputs which lets you transform the outputs covariantly and the inputs contravariantly in order to better simulate interactive FRP. See Genuinely Functional User Interfaces by Courtney and Elliott for a great example of that feature. – [J. Abrahamson Oct 2, 2014 at 20:05](https://stackoverflow.com/questions/26164135/how-fundamentally-different-are-push-pull-and-arrowized-frp#comment41026887_26164135) + + ## 2024-05-30 ### Design From 7574281d96c4693c61b35ca2c8d14538b3b90de1 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 5 Jun 2024 23:03:07 -0700 Subject: [PATCH 21/30] Implement signals and streams w separate timelines - Streams are feed-forward (no loops) - Cells allow loops through push-pull - Streams are buffered - Cells are last write wins - Streams prevent the diamond problem by zipping pairwise (events are held up until both sides have provided a value... if one stream goes faster, then it builds up in a buffer) --- .../2024-05-30-common-frp/src/common-frp.ts | 360 ------------------ sketches/2024-05-30-common-frp/src/dom.ts | 4 +- .../src/example/basic.ts | 3 +- sketches/2024-05-30-common-frp/src/index.ts | 3 + .../2024-05-30-common-frp/src/publisher.ts | 41 ++ sketches/2024-05-30-common-frp/src/shared.ts | 9 + sketches/2024-05-30-common-frp/src/signal.ts | 226 +++++++++++ sketches/2024-05-30-common-frp/src/stream.ts | 89 +++++ 8 files changed, 372 insertions(+), 363 deletions(-) delete mode 100644 sketches/2024-05-30-common-frp/src/common-frp.ts create mode 100644 sketches/2024-05-30-common-frp/src/index.ts create mode 100644 sketches/2024-05-30-common-frp/src/publisher.ts create mode 100644 sketches/2024-05-30-common-frp/src/shared.ts create mode 100644 sketches/2024-05-30-common-frp/src/signal.ts create mode 100644 sketches/2024-05-30-common-frp/src/stream.ts diff --git a/sketches/2024-05-30-common-frp/src/common-frp.ts b/sketches/2024-05-30-common-frp/src/common-frp.ts deleted file mode 100644 index b17269438..000000000 --- a/sketches/2024-05-30-common-frp/src/common-frp.ts +++ /dev/null @@ -1,360 +0,0 @@ -/* -TODO: beginnings of a push-pull FRP system using transactions. -This will enable graph cycles. -*/ -export const config = { - debug: false -} - -const debugLog = (tag: string, msg: string) => { - if (config.debug) { - console.debug(`[${tag}] ${msg}`) - } -} - -const createTransactionManager = () => { - const streams = new Map<(value: any) => void, any>() - const cells = new Map<(value: any) => void, any>() - const sinks = new Set<() => void>() - - let isScheduled = false - - const schedule = () => { - if (isScheduled) { - return - } - debugLog('TransactionManager.schedule', `transaction scheduled`) - isScheduled = true - queueMicrotask(transact) - } - - const transact = () => { - debugLog('TransactionManager.transact', 'transaction start') - // First perform all stream updates. - debugLog('TransactionManager.transact', `transact streams`) - for (const [job, value] of streams) { - job(value) - } - streams.clear() - // First perform all cell state changes. - // - Update cell state - // - Mark computed dirty - debugLog('TransactionManager.transact', `transact cells`) - for (const [job, value] of cells) { - job(value) - } - cells.clear() - // Then perform all cell state reads - // - Read cell state - // - Recompute computed cells and mark clean - debugLog('TransactionManager.transact', `transact sinks`) - for (const job of sinks) { - job() - } - sinks.clear() - isScheduled = false - debugLog('TransactionManager.transact', 'transaction end') - } - - const withStreams = (job: (value: T) => void, value: T) => { - debugLog('TransactionManager.withStreams', `queue job with value ${value}`) - streams.set(job, value) - schedule() - } - - const withCells = (job: (value: T) => void, value: T) => { - debugLog('TransactionManager.withCells', `queue job with value ${value}`) - cells.set(job, value) - schedule() - } - - const withSinks =(job: () => void) => { - debugLog('TransactionManager.withSinks', `queue job`) - sinks.add(job) - schedule() - } - - return {withStreams, withCells, withSinks} -} - -const {withStreams, withCells, withSinks} = createTransactionManager() - -export type Unsubscribe = () => void - -export type Subscriber = (value: T) => void - -/** Low-level pub-sub channel used under the hood by cells and sinks. */ -const createPublisher = () => { - const subscribers = new Set>() - - const pub = (value: T) => { - debugLog('pub', `dispatching ${value} to ${subscribers.size} subscribers`) - for (const subscriber of subscribers) { - subscriber(value) - } - } - - /** - * Subscribe to this publisher - * @param subscriber the function to call when a new value is published - * @returns Unsubscribe function - */ - const sub = (subscriber: Subscriber): Unsubscribe => { - debugLog('sub', 'subscribing') - subscribers.add(subscriber) - return () => { - debugLog('sub', 'canceling subscription') - subscribers.delete(subscriber) - } - } - - return {pub, sub} -} - -/** Combine multiple unsubscribe functions into a single unsubscribe function */ -export const combineUnsubscribes = ( - unsubscribes: Array -): Unsubscribe => () => { - for (const cancel of unsubscribes) { - cancel() - } -} - -/** Symbol for updates subscribe method */ -const __updates__ = Symbol('updates') - -export type Updates = { - [__updates__]: (subscriber: Subscriber) => Unsubscribe -} - -export type Sink = { - sink: (subscriber: Subscriber) => Unsubscribe -} - -export type Subject = { - send: (value: T) => void -} - -export type Unsubscribable = { - unsubscribe?: Unsubscribe -} - -export type ReadStream = Sink & Updates & Unsubscribable - -export type ReadWriteStream = Sink & Updates & Subject - -export const createStream = (): ReadWriteStream => { - const updates = createPublisher() - - const perform = (value: T) => { - debugLog(`stream`, `value: ${value}`) - updates.pub(value) - } - - const send = (value: T) => withStreams(perform, value) - - const sink = (subscriber: Subscriber) => updates.sub( - (value: T) => withSinks(() => subscriber(value)) - ) - - return {send, [__updates__]: updates.sub, sink} -} - -export const generateStream = ( - generate: (send: (value: T) => void) => Unsubscribe|undefined -): ReadStream => { - const {send, [__updates__]: updates, sink} = createStream() - const unsubscribe = generate(send) - return {[__updates__]: updates, sink, unsubscribe} -} - -export const mapStream = ( - stream: ReadStream, - transform: (value: T) => U -) => generateStream((send: (value: U) => void) => { - const subscribe = (value: T) => send(transform(value)) - return stream[__updates__](subscribe) -}) - -export const filterStream = ( - stream: ReadStream, - predicate: (value: T) => boolean -) => generateStream(send => { - const subscribe = (value: T) => { - if (predicate(value)) { - send(value) - } - } - return stream[__updates__](subscribe) -}) - -const isEqual = Object.is - -export type Gettable = { - get(): T -} - -const sample = (container: Gettable) => container.get() - -export type ReadCell = Gettable & Updates & Sink & Unsubscribable -export type ReadWriteCell = Gettable & Updates & Sink - -export const createCell = (initial: T) => { - const updates = createPublisher() - - let state = initial - - const get = () => state - - const performUpdate = (value: T) => { - // Only perform update if state has actually changed - if (!isEqual(state, value)) { - state = value - debugLog(`cell`, `value: ${state}`) - updates.pub() - } - } - - const send = (value: T) => withCells(performUpdate, value) - - const sink = (subscriber: Subscriber) => { - const job = () => subscriber(get()) - job() - return updates.sub(() => withSinks(job)) - } - - return { - get, - send, - [__updates__]: updates.sub, - sink - } -} - -export type createComputed = { - ( - upstreams: [ReadCell, ReadCell], - compute: (a: A, b: B) => Z - ): ReadCell - ( - upstreams: [ReadCell, ReadCell, ReadCell], - compute: (a: A, b: B, c: C) => Z - ): ReadCell - ( - upstreams: [ReadCell, ReadCell, ReadCell, ReadCell], - compute: (a: A, b: B, c: C, d: D) => Z - ): ReadCell - ( - upstreams: [ - ReadCell, - ReadCell, - ReadCell, - ReadCell, - ReadCell], - compute: (a: A, b: B, c: C, d: D, e: E) => Z - ): ReadCell - ( - upstreams: [ - ReadCell, - ReadCell, - ReadCell, - ReadCell, - ReadCell, - ReadCell - ], - compute: (a: A, b: B, c: C, d: D, e: E, f: F) => Z - ): ReadCell - ( - upstreams: [ - ReadCell, - ReadCell, - ReadCell, - ReadCell, - ReadCell, - ReadCell, - ReadCell - ], - compute: (a: A, b: B, c: C, d: D, e: E, f: F, g: G) => Z - ): ReadCell - ( - upstreams: [ - ReadCell, - ReadCell, - ReadCell, - ReadCell, - ReadCell, - ReadCell, - ReadCell, - ReadCell - ], - compute: (a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H) => Z - ): ReadCell -} - -export const createComputed: createComputed = ( - upstreams: Array>, - compute: (...values: Array) => any -): ReadCell => { - const updates = createPublisher() - - const recompute = () => compute(...upstreams.map(sample)) - - let isDirty = false - let state = recompute() - - const performUpdate = () => { - debugLog(`computed`, `mark dirty`) - isDirty = true - updates.pub() - } - - const unsubscribe = combineUnsubscribes( - upstreams.map(cell => cell[__updates__](performUpdate)) - ) - - const get = () => { - if (isDirty) { - state = recompute() - debugLog(`computed`, `recomputed state: ${state}`) - isDirty = false - } - return state - } - - const sink = (subscriber: Subscriber) => { - const job = () => subscriber(get()) - job() - return updates.sub(() => withSinks(job)) - } - - return { - get, - [__updates__]: updates.sub, - sink, - unsubscribe: unsubscribe - } -} - -/** - * Scan a stream producing a cell that contains the reductions of each step - * of the reduce operation. - */ -export const scan = ( - stream: ReadStream, - step: (state: U, value: T) => U, - initial: U -): ReadCell => { - const {get, [__updates__]: updates, sink, send} = createCell(initial) - const unsubscribe = stream[__updates__]((value: T) => { - send(step(get(), value)) - }) - return {get, [__updates__]: updates, sink, unsubscribe} -} - -/** - * Hold the latest value from a stream in a cell. - */ -export const hold = ( - stream: ReadStream, - initial: T -) => scan(stream, (_, value) => value, initial) \ No newline at end of file diff --git a/sketches/2024-05-30-common-frp/src/dom.ts b/sketches/2024-05-30-common-frp/src/dom.ts index 007750041..d45b760f0 100644 --- a/sketches/2024-05-30-common-frp/src/dom.ts +++ b/sketches/2024-05-30-common-frp/src/dom.ts @@ -1,9 +1,9 @@ -import { generateStream } from './common-frp' +import { stream } from './index' export const events = ( element: HTMLElement, name: string -) => generateStream((send: (value: Event) => void) => { +) => stream.create((send: (value: Event) => void) => { element.addEventListener(name, send) return () => element.removeEventListener(name, send) }) \ No newline at end of file diff --git a/sketches/2024-05-30-common-frp/src/example/basic.ts b/sketches/2024-05-30-common-frp/src/example/basic.ts index 43cf04576..c87777bea 100644 --- a/sketches/2024-05-30-common-frp/src/example/basic.ts +++ b/sketches/2024-05-30-common-frp/src/example/basic.ts @@ -1,4 +1,5 @@ -import {scan, config} from '../common-frp' +import { config } from '../index' +import { scan } from '../stream' import { events } from '../dom' config.debug = true diff --git a/sketches/2024-05-30-common-frp/src/index.ts b/sketches/2024-05-30-common-frp/src/index.ts new file mode 100644 index 000000000..8deebcd59 --- /dev/null +++ b/sketches/2024-05-30-common-frp/src/index.ts @@ -0,0 +1,3 @@ +export * as stream from './stream' +export * as signal from './signal' +export {config} from './shared' \ No newline at end of file diff --git a/sketches/2024-05-30-common-frp/src/publisher.ts b/sketches/2024-05-30-common-frp/src/publisher.ts new file mode 100644 index 000000000..187417391 --- /dev/null +++ b/sketches/2024-05-30-common-frp/src/publisher.ts @@ -0,0 +1,41 @@ +import { debug } from "./shared" + +export type Cancel = () => void + +export type Send = (value: T) => void + +/** Low-level pub-sub channel used under the hood by cells and sinks. */ +export const createPublisher = () => { + const subscribers = new Set>() + + const pub = (value: T) => { + for (const subscriber of subscribers) { + subscriber(value) + } + } + + /** + * Subscribe to this publisher + * @param subscriber the function to call when a new value is published + * @returns Unsubscribe function + */ + const sub = (subscriber: Send): Cancel => { + debug('sub', 'subscribing') + subscribers.add(subscriber) + return () => { + debug('sub', 'canceling subscription') + subscribers.delete(subscriber) + } + } + + return {pub, sub} +} + +/** Combine multiple unsubscribe functions into a single unsubscribe function */ +export const combineCancels = ( + unsubscribes: Array +): Cancel => () => { + for (const cancel of unsubscribes) { + cancel() + } +} \ No newline at end of file diff --git a/sketches/2024-05-30-common-frp/src/shared.ts b/sketches/2024-05-30-common-frp/src/shared.ts new file mode 100644 index 000000000..3b193fdb3 --- /dev/null +++ b/sketches/2024-05-30-common-frp/src/shared.ts @@ -0,0 +1,9 @@ +export const config = { + debug: false +} + +export const debug = (tag: string, msg: string) => { + if (config.debug) { + console.debug(tag, msg) + } +} diff --git a/sketches/2024-05-30-common-frp/src/signal.ts b/sketches/2024-05-30-common-frp/src/signal.ts new file mode 100644 index 000000000..a65cf88b2 --- /dev/null +++ b/sketches/2024-05-30-common-frp/src/signal.ts @@ -0,0 +1,226 @@ +import { debug } from './shared' +import { + createPublisher, + Send, + Cancel, + combineCancels +} from './publisher' + +const createTransactionManager = () => { + const updates = new Map<(value: any) => void, any>() + const reads = new Set<() => void>() + + let isScheduled = false + + const schedule = () => { + if (isScheduled) { + return + } + debug('TransactionManager.schedule', `transaction scheduled`) + isScheduled = true + queueMicrotask(transact) + } + + const transact = () => { + // First perform all cell state changes. + // - Update cell state + // - Mark computed dirty + debug('TransactionManager.transact', `updates`) + for (const [job, value] of updates) { + job(value) + } + updates.clear() + // Then perform all cell state reads + // - Read cell state + // - Recompute computed cells and mark clean + debug('TransactionManager.transact', `reads`) + for (const job of reads) { + job() + } + reads.clear() + isScheduled = false + debug('TransactionManager.transact', 'transaction end') + } + + const withUpdates = (job: (value: T) => void, value: T) => { + debug('TransactionManager.withUpdates', `queue job with value ${value}`) + updates.set(job, value) + schedule() + } + + const withReads = (job: () => void) => { + debug('TransactionManager.withReads', `queue job`) + reads.add(job) + schedule() + } + + return {withUpdates, withReads} +} + +const {withUpdates, withReads} = createTransactionManager() + +/** Symbol for updates subscribe method */ +export const __updates__ = Symbol('updates') + +export type Updates = { + [__updates__]: (subscriber: Send) => Cancel +} + +export type Sink = { + sink: (subscriber: Send) => Cancel +} + +export type Subject = { + send: (value: T) => void +} + +export type Unsubscribable = { + unsubscribe?: Cancel +} + +const isEqual = Object.is + +export type Gettable = { + get(): T +} + +const sample = (container: Gettable) => container.get() + +export type Signal = Gettable & Updates & Sink & Unsubscribable +export type SignalSubject = Gettable & Updates & Sink & Subject + +export const create = (initial: T) => { + const updates = createPublisher() + + let state = initial + + const get = () => state + + const performUpdate = (value: T) => { + // Only perform update if state has actually changed + if (!isEqual(state, value)) { + state = value + debug('cell', `value: ${state}`) + updates.pub() + } + } + + const send = (value: T) => withUpdates(performUpdate, value) + + const sink = (subscriber: Send) => { + const job = () => subscriber(get()) + job() + return updates.sub(() => withReads(job)) + } + + return { + get, + send, + [__updates__]: updates.sub, + sink + } +} + +export type computed = { + ( + upstreams: [Signal, Signal], + compute: (a: A, b: B) => Z + ): Signal + ( + upstreams: [Signal, Signal, Signal], + compute: (a: A, b: B, c: C) => Z + ): Signal + ( + upstreams: [Signal, Signal, Signal, Signal], + compute: (a: A, b: B, c: C, d: D) => Z + ): Signal + ( + upstreams: [ + Signal, + Signal, + Signal, + Signal, + Signal], + compute: (a: A, b: B, c: C, d: D, e: E) => Z + ): Signal + ( + upstreams: [ + Signal, + Signal, + Signal, + Signal, + Signal, + Signal + ], + compute: (a: A, b: B, c: C, d: D, e: E, f: F) => Z + ): Signal + ( + upstreams: [ + Signal, + Signal, + Signal, + Signal, + Signal, + Signal, + Signal + ], + compute: (a: A, b: B, c: C, d: D, e: E, f: F, g: G) => Z + ): Signal + ( + upstreams: [ + Signal, + Signal, + Signal, + Signal, + Signal, + Signal, + Signal, + Signal + ], + compute: (a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H) => Z + ): Signal +} + +export const computed: computed = ( + upstreams: Array>, + compute: (...values: Array) => any +): Signal => { + const updates = createPublisher() + + const recompute = () => compute(...upstreams.map(sample)) + + let isDirty = false + let state = recompute() + + const performUpdate = () => { + debug('computed', 'mark dirty') + isDirty = true + updates.pub() + } + + const unsubscribe = combineCancels( + upstreams.map(cell => cell[__updates__](performUpdate)) + ) + + const get = () => { + if (isDirty) { + state = recompute() + debug(`computed`, `recomputed state: ${state}`) + isDirty = false + } + return state + } + + const sink = (subscriber: Send) => { + const job = () => subscriber(get()) + job() + return updates.sub(() => withReads(job)) + } + + return { + get, + [__updates__]: updates.sub, + sink, + unsubscribe: unsubscribe + } +} \ No newline at end of file diff --git a/sketches/2024-05-30-common-frp/src/stream.ts b/sketches/2024-05-30-common-frp/src/stream.ts new file mode 100644 index 000000000..770e112cf --- /dev/null +++ b/sketches/2024-05-30-common-frp/src/stream.ts @@ -0,0 +1,89 @@ +import { debug } from "./shared" +import { createPublisher, Send, Cancel, combineCancels } from "./publisher" +import { create as createSignal, Signal, __updates__ } from "./signal" + +export type Stream = { + sink: (subscriber: Send) => Cancel + cancel?: Cancel +} + +export const create = ( + generate: (send: Send) => Cancel|undefined +): Stream => { + const {pub, sub: sink} = createPublisher() + const cancel = generate(pub) + return {sink, cancel} +} + +export const map = ( + stream: Stream, + transform: (value: T) => U +) => create(send => { + return stream.sink((value: T) => send(transform(value))) +}) + +export const filter = ( + stream: Stream, + predicate: (value: T) => boolean +) => create(send => { + return stream.sink(value => { + if (predicate(value)) { + send(value) + } + }) +}) + +export const zip = ( + left: Stream, + right: Stream, + combine: (left: T, right: U) => V +) => create(send => { + const leftQueue: Array = [] + const rightQueue: Array = [] + + const forward = () => { + if (leftQueue.length > 0 && rightQueue.length > 0) { + const leftValue = leftQueue.shift()! + const rightValue = rightQueue.shift()! + const value = combine(leftValue, rightValue) + debug('join', `dispatching value: ${value} from ${leftValue} and ${rightValue}`) + send(value) + } + } + + const cancelLeft = left.sink(value => { + leftQueue.push(value) + forward() + }) + + const cancelRight = right.sink(value => { + rightQueue.push(value) + forward() + }) + + return combineCancels([cancelLeft, cancelRight]) +}) + +/** + * Scan a stream producing a cell that contains the reductions of each step + * of the reduce operation. + */ +export const scan = ( + stream: Stream, + step: (state: U, value: T) => U, + initial: U +): Signal => { + const {get, [__updates__]: updates, sink, send} = createSignal(initial) + const unsubscribe = stream.sink((value: T) => { + send(step(get(), value)) + }) + return {get, [__updates__]: updates, sink, unsubscribe} +} + +/** + * Hold the latest value from a stream in a cell. + */ +export const hold = ( + stream: Signal, + initial: T +) => scan(stream, (_, value) => value, initial) \ No newline at end of file From bce9c4136fa0279c4af8b703b8d62d20b37a8ab4 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 6 Jun 2024 10:30:46 -0700 Subject: [PATCH 22/30] Add pipe and operators --- sketches/2024-05-30-common-frp/src/dom.ts | 4 +- .../2024-05-30-common-frp/src/operators.ts | 111 ++++++++++++++++++ sketches/2024-05-30-common-frp/src/signal.ts | 6 +- sketches/2024-05-30-common-frp/src/stream.ts | 12 +- 4 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 sketches/2024-05-30-common-frp/src/operators.ts diff --git a/sketches/2024-05-30-common-frp/src/dom.ts b/sketches/2024-05-30-common-frp/src/dom.ts index d45b760f0..0099a3f0e 100644 --- a/sketches/2024-05-30-common-frp/src/dom.ts +++ b/sketches/2024-05-30-common-frp/src/dom.ts @@ -1,9 +1,9 @@ -import { stream } from './index' +import { createStream } from './stream' export const events = ( element: HTMLElement, name: string -) => stream.create((send: (value: Event) => void) => { +) => createStream((send: (value: Event) => void) => { element.addEventListener(name, send) return () => element.removeEventListener(name, send) }) \ No newline at end of file diff --git a/sketches/2024-05-30-common-frp/src/operators.ts b/sketches/2024-05-30-common-frp/src/operators.ts new file mode 100644 index 000000000..e2811d6ff --- /dev/null +++ b/sketches/2024-05-30-common-frp/src/operators.ts @@ -0,0 +1,111 @@ +import { + map as mapStream, + filter as filterStream, + zip as zipStreams, + scan as scanStream, + hold as holdStream, + Stream +} from './stream' + +export type UnaryFn = (a: A) => B + +export type Pipe = { + ( + value: A, + a2b: UnaryFn, + b2c: UnaryFn + ): C + + ( + value: A, + a2b: UnaryFn, + b2c: UnaryFn, + c2d: UnaryFn + ): D + + ( + value: A, + a2b: UnaryFn, + b2c: UnaryFn, + c2d: UnaryFn, + d2e: UnaryFn + ): E + + ( + value: A, + a2b: UnaryFn, + b2c: UnaryFn, + c2d: UnaryFn, + d2e: UnaryFn, + e2f: UnaryFn + ): F + + ( + value: A, + a2b: UnaryFn, + b2c: UnaryFn, + c2d: UnaryFn, + d2e: UnaryFn, + e2f: UnaryFn, + f2g: UnaryFn + ): G + + ( + value: A, + a2b: UnaryFn, + b2c: UnaryFn, + c2d: UnaryFn, + d2e: UnaryFn, + e2f: UnaryFn, + f2g: UnaryFn, + g2h: UnaryFn + ): H + + ( + value: A, + a2b: UnaryFn, + b2c: UnaryFn, + c2d: UnaryFn, + d2e: UnaryFn, + e2f: UnaryFn, + f2g: UnaryFn, + g2h: UnaryFn, + h2i: UnaryFn + ): I +} + +/** Pipe a value through a series of functions */ +export const pipe: Pipe = ( + value: any, + ...fns: [(value: any) => any] +): any => fns.reduce((value: any, fn: UnaryFn) => fn(value), value) + +/** Map a stream of values */ +export const map = ( + transform: UnaryFn +) => (stream: Stream) => mapStream(stream, transform) + +/** Filter a stream of values using a predicate function. */ +export const filter = ( + predicate: UnaryFn +) => (stream: Stream) => filterStream(stream, predicate) + +/** Scan a stream, accumulating step state in a cell */ +export const scan = ( + step: (state: U, value: T) => U, initial: U +) => (stream: Stream) => scanStream(stream, step, initial) + +export const hold = ( + initial: T +) => (stream: Stream) => holdStream(stream, initial) + +export const zip = ( + right: Stream +) => ( + left: Stream, + combine: (left: T, right: U) => V, +) => zipStreams( + left, + right, + combine +) \ No newline at end of file diff --git a/sketches/2024-05-30-common-frp/src/signal.ts b/sketches/2024-05-30-common-frp/src/signal.ts index a65cf88b2..8f32fcbc9 100644 --- a/sketches/2024-05-30-common-frp/src/signal.ts +++ b/sketches/2024-05-30-common-frp/src/signal.ts @@ -89,7 +89,7 @@ const sample = (container: Gettable) => container.get() export type Signal = Gettable & Updates & Sink & Unsubscribable export type SignalSubject = Gettable & Updates & Sink & Subject -export const create = (initial: T) => { +export const createSignal = (initial: T) => { const updates = createPublisher() let state = initial @@ -121,7 +121,7 @@ export const create = (initial: T) => { } } -export type computed = { +export type createComputed = { ( upstreams: [Signal, Signal], compute: (a: A, b: B) => Z @@ -181,7 +181,7 @@ export type computed = { ): Signal } -export const computed: computed = ( +export const computed: createComputed = ( upstreams: Array>, compute: (...values: Array) => any ): Signal => { diff --git a/sketches/2024-05-30-common-frp/src/stream.ts b/sketches/2024-05-30-common-frp/src/stream.ts index 770e112cf..754ccd1f1 100644 --- a/sketches/2024-05-30-common-frp/src/stream.ts +++ b/sketches/2024-05-30-common-frp/src/stream.ts @@ -1,13 +1,13 @@ import { debug } from "./shared" import { createPublisher, Send, Cancel, combineCancels } from "./publisher" -import { create as createSignal, Signal, __updates__ } from "./signal" +import { createSignal as createSignal, Signal, __updates__ } from "./signal" export type Stream = { sink: (subscriber: Send) => Cancel cancel?: Cancel } -export const create = ( +export const createStream = ( generate: (send: Send) => Cancel|undefined ): Stream => { const {pub, sub: sink} = createPublisher() @@ -18,14 +18,14 @@ export const create = ( export const map = ( stream: Stream, transform: (value: T) => U -) => create(send => { +) => createStream(send => { return stream.sink((value: T) => send(transform(value))) }) export const filter = ( stream: Stream, predicate: (value: T) => boolean -) => create(send => { +) => createStream(send => { return stream.sink(value => { if (predicate(value)) { send(value) @@ -37,7 +37,7 @@ export const zip = ( left: Stream, right: Stream, combine: (left: T, right: U) => V -) => create(send => { +) => createStream(send => { const leftQueue: Array = [] const rightQueue: Array = [] @@ -84,6 +84,6 @@ export const scan = ( * Hold the latest value from a stream in a cell. */ export const hold = ( - stream: Signal, + stream: Stream, initial: T ) => scan(stream, (_, value) => value, initial) \ No newline at end of file From 4df5930de1cb2495736691fcfb1d73396066f164 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 6 Jun 2024 10:59:09 -0700 Subject: [PATCH 23/30] Separate sink/effect from type We probably don't want to expose these to library consumers in some cases? Removing them as methods and exposing as functions gives us more optionality in what to expose to consumers. TODO: need to figure out a cleverer way of hiding subscription endpoint than a symbol. --- sketches/2024-05-30-common-frp/src/dom.ts | 4 +- .../src/example/basic.ts | 3 +- .../2024-05-30-common-frp/src/operators.ts | 25 ++++--- .../2024-05-30-common-frp/src/publisher.ts | 6 +- sketches/2024-05-30-common-frp/src/signal.ts | 49 ++++++-------- sketches/2024-05-30-common-frp/src/stream.ts | 67 +++++++++++-------- 6 files changed, 83 insertions(+), 71 deletions(-) diff --git a/sketches/2024-05-30-common-frp/src/dom.ts b/sketches/2024-05-30-common-frp/src/dom.ts index 0099a3f0e..33aa1cdeb 100644 --- a/sketches/2024-05-30-common-frp/src/dom.ts +++ b/sketches/2024-05-30-common-frp/src/dom.ts @@ -1,9 +1,9 @@ -import { createStream } from './stream' +import { stream } from './stream' export const events = ( element: HTMLElement, name: string -) => createStream((send: (value: Event) => void) => { +) => stream((send: (value: Event) => void) => { element.addEventListener(name, send) return () => element.removeEventListener(name, send) }) \ No newline at end of file diff --git a/sketches/2024-05-30-common-frp/src/example/basic.ts b/sketches/2024-05-30-common-frp/src/example/basic.ts index c87777bea..8bfd66e8d 100644 --- a/sketches/2024-05-30-common-frp/src/example/basic.ts +++ b/sketches/2024-05-30-common-frp/src/example/basic.ts @@ -1,5 +1,6 @@ import { config } from '../index' import { scan } from '../stream' +import { effect } from '../signal' import { events } from '../dom' config.debug = true @@ -10,7 +11,7 @@ const clicks = events(button, 'click') const clickCount = scan(clicks, (state, _) => state + 1, 0) -clickCount.sink(x => { +effect(clickCount, x => { button.textContent = `Clicks: ${x}` console.log(x) }) \ No newline at end of file diff --git a/sketches/2024-05-30-common-frp/src/operators.ts b/sketches/2024-05-30-common-frp/src/operators.ts index e2811d6ff..38a61b38c 100644 --- a/sketches/2024-05-30-common-frp/src/operators.ts +++ b/sketches/2024-05-30-common-frp/src/operators.ts @@ -90,15 +90,10 @@ export const filter = ( predicate: UnaryFn ) => (stream: Stream) => filterStream(stream, predicate) -/** Scan a stream, accumulating step state in a cell */ -export const scan = ( - step: (state: U, value: T) => U, initial: U -) => (stream: Stream) => scanStream(stream, step, initial) - -export const hold = ( - initial: T -) => (stream: Stream) => holdStream(stream, initial) - +/** + * Zip two streams together. + * Will buffer left and right values until both are available. + */ export const zip = ( right: Stream ) => ( @@ -108,4 +103,14 @@ export const zip = ( left, right, combine -) \ No newline at end of file +) + +/** Scan a stream, accumulating step state in a cell */ +export const scan = ( + step: (state: U, value: T) => U, initial: U +) => (stream: Stream) => scanStream(stream, step, initial) + +/** Hold the latest value of a stream in a cell */ +export const hold = ( + initial: T +) => (stream: Stream) => holdStream(stream, initial) \ No newline at end of file diff --git a/sketches/2024-05-30-common-frp/src/publisher.ts b/sketches/2024-05-30-common-frp/src/publisher.ts index 187417391..eaf8f9896 100644 --- a/sketches/2024-05-30-common-frp/src/publisher.ts +++ b/sketches/2024-05-30-common-frp/src/publisher.ts @@ -2,6 +2,10 @@ import { debug } from "./shared" export type Cancel = () => void +export type Cancellable = { + cancel?: Cancel +} + export type Send = (value: T) => void /** Low-level pub-sub channel used under the hood by cells and sinks. */ @@ -38,4 +42,4 @@ export const combineCancels = ( for (const cancel of unsubscribes) { cancel() } -} \ No newline at end of file +} diff --git a/sketches/2024-05-30-common-frp/src/signal.ts b/sketches/2024-05-30-common-frp/src/signal.ts index 8f32fcbc9..821e81363 100644 --- a/sketches/2024-05-30-common-frp/src/signal.ts +++ b/sketches/2024-05-30-common-frp/src/signal.ts @@ -3,6 +3,7 @@ import { createPublisher, Send, Cancel, + Cancellable, combineCancels } from './publisher' @@ -66,18 +67,10 @@ export type Updates = { [__updates__]: (subscriber: Send) => Cancel } -export type Sink = { - sink: (subscriber: Send) => Cancel -} - export type Subject = { send: (value: T) => void } -export type Unsubscribable = { - unsubscribe?: Cancel -} - const isEqual = Object.is export type Gettable = { @@ -86,10 +79,20 @@ export type Gettable = { const sample = (container: Gettable) => container.get() -export type Signal = Gettable & Updates & Sink & Unsubscribable -export type SignalSubject = Gettable & Updates & Sink & Subject +export type Signal = Gettable & Updates & Cancellable +export type SignalSubject = Gettable & Updates & Subject + +/** React to a signal, producing an effect any time it changes */ +export const effect = ( + signal: Signal, + effect: Send +) => { + const job = () => effect(signal.get()) + job() + return signal[__updates__](() => withReads(job)) +} -export const createSignal = (initial: T) => { +export const signal = (initial: T) => { const updates = createPublisher() let state = initial @@ -107,21 +110,14 @@ export const createSignal = (initial: T) => { const send = (value: T) => withUpdates(performUpdate, value) - const sink = (subscriber: Send) => { - const job = () => subscriber(get()) - job() - return updates.sub(() => withReads(job)) - } - return { get, send, - [__updates__]: updates.sub, - sink + [__updates__]: updates.sub } } -export type createComputed = { +export type computed = { ( upstreams: [Signal, Signal], compute: (a: A, b: B) => Z @@ -181,7 +177,7 @@ export type createComputed = { ): Signal } -export const computed: createComputed = ( +export const computed: computed = ( upstreams: Array>, compute: (...values: Array) => any ): Signal => { @@ -198,7 +194,7 @@ export const computed: createComputed = ( updates.pub() } - const unsubscribe = combineCancels( + const cancel = combineCancels( upstreams.map(cell => cell[__updates__](performUpdate)) ) @@ -211,16 +207,9 @@ export const computed: createComputed = ( return state } - const sink = (subscriber: Send) => { - const job = () => subscriber(get()) - job() - return updates.sub(() => withReads(job)) - } - return { get, [__updates__]: updates.sub, - sink, - unsubscribe: unsubscribe + cancel } } \ No newline at end of file diff --git a/sketches/2024-05-30-common-frp/src/stream.ts b/sketches/2024-05-30-common-frp/src/stream.ts index 754ccd1f1..315e2b74b 100644 --- a/sketches/2024-05-30-common-frp/src/stream.ts +++ b/sketches/2024-05-30-common-frp/src/stream.ts @@ -1,43 +1,61 @@ import { debug } from "./shared" import { createPublisher, Send, Cancel, combineCancels } from "./publisher" -import { createSignal as createSignal, Signal, __updates__ } from "./signal" +import { signal as signal, Signal, __updates__ } from "./signal" + +const __sink__ = Symbol('sink') export type Stream = { - sink: (subscriber: Send) => Cancel + [__sink__]: (subscriber: Send) => Cancel cancel?: Cancel } -export const createStream = ( +/** + * Subscribe to a stream, receiving all updates after the point of subscription. + * @return a function to cancel the subscription. + */ +export const sink = ( + upstream: Stream, + subscriber: Send +) => upstream[__sink__](subscriber) + +/** Create a new stream source */ +export const stream = ( generate: (send: Send) => Cancel|undefined ): Stream => { - const {pub, sub: sink} = createPublisher() + const {pub, sub} = createPublisher() const cancel = generate(pub) - return {sink, cancel} + return {[__sink__]: sub, cancel} } +/** Map a stream of values */ export const map = ( - stream: Stream, + upstream: Stream, transform: (value: T) => U -) => createStream(send => { - return stream.sink((value: T) => send(transform(value))) +) => stream(send => { + return sink(upstream, value => send(transform(value))) }) +/** Filter a stream of values using a predicate function. */ export const filter = ( - stream: Stream, + upstream: Stream, predicate: (value: T) => boolean -) => createStream(send => { - return stream.sink(value => { +) => stream(send => { + return sink(upstream, value => { if (predicate(value)) { send(value) } }) }) +/** + * Zip two streams together. + * Will buffer left and right values until both are available. + */ export const zip = ( left: Stream, right: Stream, combine: (left: T, right: U) => V -) => createStream(send => { +) => stream(send => { const leftQueue: Array = [] const rightQueue: Array = [] @@ -51,12 +69,12 @@ export const zip = ( } } - const cancelLeft = left.sink(value => { + const cancelLeft = sink(left, value => { leftQueue.push(value) forward() }) - const cancelRight = right.sink(value => { + const cancelRight = sink(right, value => { rightQueue.push(value) forward() }) @@ -64,26 +82,21 @@ export const zip = ( return combineCancels([cancelLeft, cancelRight]) }) -/** - * Scan a stream producing a cell that contains the reductions of each step - * of the reduce operation. - */ +/** Scan a stream, accumulating step state in a cell */ export const scan = ( - stream: Stream, + upstream: Stream, step: (state: U, value: T) => U, initial: U ): Signal => { - const {get, [__updates__]: updates, sink, send} = createSignal(initial) - const unsubscribe = stream.sink((value: T) => { + const {get, [__updates__]: updates, send} = signal(initial) + const cancel = sink(upstream, (value: T) => { send(step(get(), value)) }) - return {get, [__updates__]: updates, sink, unsubscribe} + return {get, [__updates__]: updates, cancel} } -/** - * Hold the latest value from a stream in a cell. - */ +/** Hold the latest value of a stream in a cell */ export const hold = ( - stream: Stream, + upstream: Stream, initial: T -) => scan(stream, (_, value) => value, initial) \ No newline at end of file +) => scan(upstream, (_, value) => value, initial) \ No newline at end of file From ab6957e0844009701239080b5726c278ab7a1165 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 6 Jun 2024 16:20:30 -0700 Subject: [PATCH 24/30] Update notes --- sketches/2024-05-30-common-frp/NOTES.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sketches/2024-05-30-common-frp/NOTES.md b/sketches/2024-05-30-common-frp/NOTES.md index 086a034c8..8ca3691e1 100644 --- a/sketches/2024-05-30-common-frp/NOTES.md +++ b/sketches/2024-05-30-common-frp/NOTES.md @@ -72,14 +72,13 @@ Implications: Qualities: +- Comes in discrete and continuous flavors. We only care about discrete for our purposes. - Behaviors and events (also called cells and streams, or signals and streams) - Behaviors are reactive containers for state (think spreadsheet cell) - Events are streams of events over time - Both are synchronized by a transaction system that makes sure changes happen during discrete "moments" in time, and inconsistent graph states are not observable. -- Comes in discrete and continuous flavors. We only care about discrete for our purposes. - Resulting computation graph is pure. - Theory pioneered by Conal Elliott. -- Full Turing-complete theory of reactive computation. - 10 primitives: - map, merge, hold, snapshot, filter, lift, never, constant, sample, and switch. (Functional Reactive Programming, 2.3, Manning) From 9b7ade8b55d41d366343a4da476885cf9d2c68cc Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 6 Jun 2024 16:23:22 -0700 Subject: [PATCH 25/30] Add select operator --- sketches/2024-05-30-common-frp/src/operators.ts | 14 ++++++++++++-- sketches/2024-05-30-common-frp/src/stream.ts | 12 ++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/sketches/2024-05-30-common-frp/src/operators.ts b/sketches/2024-05-30-common-frp/src/operators.ts index 38a61b38c..299bdbc5a 100644 --- a/sketches/2024-05-30-common-frp/src/operators.ts +++ b/sketches/2024-05-30-common-frp/src/operators.ts @@ -83,12 +83,22 @@ export const pipe: Pipe = ( /** Map a stream of values */ export const map = ( transform: UnaryFn -) => (stream: Stream) => mapStream(stream, transform) +) => ( + stream: Stream +) => mapStream(stream, transform) + +export const select = ( + key: U +) => ( + stream: Stream +) => mapStream(stream, (o: T) => o[key]) /** Filter a stream of values using a predicate function. */ export const filter = ( predicate: UnaryFn -) => (stream: Stream) => filterStream(stream, predicate) +) => ( + stream: Stream +) => filterStream(stream, predicate) /** * Zip two streams together. diff --git a/sketches/2024-05-30-common-frp/src/stream.ts b/sketches/2024-05-30-common-frp/src/stream.ts index 315e2b74b..ad8f16a79 100644 --- a/sketches/2024-05-30-common-frp/src/stream.ts +++ b/sketches/2024-05-30-common-frp/src/stream.ts @@ -35,6 +35,18 @@ export const map = ( return sink(upstream, value => send(transform(value))) }) +/** Get a key from an object */ +const getKey = ( + obj: T, + key: U +) => obj[key]; + +/** Select a key from an object */ +export const select = ( + upstream: Stream, + key: U +) => map(upstream, (o: T) => getKey(o, key)) + /** Filter a stream of values using a predicate function. */ export const filter = ( upstream: Stream, From ab5531b1eedde6f7c7867b4d40a276f1a1136e8c Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 6 Jun 2024 16:23:36 -0700 Subject: [PATCH 26/30] Update package --- sketches/2024-05-30-common-frp/package-lock.json | 16 ++++++++++++++++ sketches/2024-05-30-common-frp/package.json | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/sketches/2024-05-30-common-frp/package-lock.json b/sketches/2024-05-30-common-frp/package-lock.json index fa314be5b..b96d60013 100644 --- a/sketches/2024-05-30-common-frp/package-lock.json +++ b/sketches/2024-05-30-common-frp/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "MIT", "devDependencies": { + "@types/node": "^20.14.2", "typescript": "^5.4.5", "vite": "^5.2.12" } @@ -595,6 +596,15 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, + "node_modules/@types/node": { + "version": "20.14.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.2.tgz", + "integrity": "sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/esbuild": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", @@ -756,6 +766,12 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "node_modules/vite": { "version": "5.2.12", "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.12.tgz", diff --git a/sketches/2024-05-30-common-frp/package.json b/sketches/2024-05-30-common-frp/package.json index 825ac5858..33da18501 100644 --- a/sketches/2024-05-30-common-frp/package.json +++ b/sketches/2024-05-30-common-frp/package.json @@ -4,8 +4,8 @@ "description": "Common functional reactive programming utilities", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", "build": "vite build", + "bundle": "vite build", "preview": "vite preview", "dev": "vite" }, @@ -15,6 +15,7 @@ "author": "Gordon Brander", "license": "MIT", "devDependencies": { + "@types/node": "^20.14.2", "typescript": "^5.4.5", "vite": "^5.2.12" } From 780fbb62dca06e7c5552566e57f54ebb0cf88a9c Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 6 Jun 2024 16:23:54 -0700 Subject: [PATCH 27/30] Update tsconfig --- sketches/2024-05-30-common-frp/tsconfig.json | 55 +++++++++++--------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/sketches/2024-05-30-common-frp/tsconfig.json b/sketches/2024-05-30-common-frp/tsconfig.json index 6cd254116..2ee993412 100644 --- a/sketches/2024-05-30-common-frp/tsconfig.json +++ b/sketches/2024-05-30-common-frp/tsconfig.json @@ -1,27 +1,30 @@ { - "compilerOptions": { - "target": "es2017", - "module": "es2015", - "moduleResolution": "node", - "lib": ["esnext.array", "esnext", "es2017", "dom"], - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "inlineSources": true, - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitThis": true, - "outDir": "./", - // Only necessary because @types/uglify-js can't find types for source-map - "skipLibCheck": true, - "experimentalDecorators": true - }, - "include": [ - "src/**/*.ts" - ], - "exclude": [] - } \ No newline at end of file + "compilerOptions": { + "composite": true, + "target": "es2022", + "module": "NodeNext", + "lib": ["es2022", "dom"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "inlineSources": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitThis": true, + "moduleResolution": "NodeNext", + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "importHelpers": true, + "stripInternal": true, + "noImplicitOverride": true, + "skipLibCheck": true, + "types": [] + }, + "include": [ + "src/**/*" + ] +} From ad55d54363a2d39a45d2f0607122e551003a5fd4 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 6 Jun 2024 16:24:49 -0700 Subject: [PATCH 28/30] Fix select operator --- sketches/2024-05-30-common-frp/src/example/basic.ts | 2 +- sketches/2024-05-30-common-frp/src/operators.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/sketches/2024-05-30-common-frp/src/example/basic.ts b/sketches/2024-05-30-common-frp/src/example/basic.ts index 8bfd66e8d..9c93eb5ae 100644 --- a/sketches/2024-05-30-common-frp/src/example/basic.ts +++ b/sketches/2024-05-30-common-frp/src/example/basic.ts @@ -14,4 +14,4 @@ const clickCount = scan(clicks, (state, _) => state + 1, 0) effect(clickCount, x => { button.textContent = `Clicks: ${x}` console.log(x) -}) \ No newline at end of file +}) diff --git a/sketches/2024-05-30-common-frp/src/operators.ts b/sketches/2024-05-30-common-frp/src/operators.ts index 299bdbc5a..80f7f0afb 100644 --- a/sketches/2024-05-30-common-frp/src/operators.ts +++ b/sketches/2024-05-30-common-frp/src/operators.ts @@ -1,5 +1,6 @@ import { map as mapStream, + select as selectStream, filter as filterStream, zip as zipStreams, scan as scanStream, @@ -91,7 +92,7 @@ export const select = ( key: U ) => ( stream: Stream -) => mapStream(stream, (o: T) => o[key]) +) => selectStream(stream, key) /** Filter a stream of values using a predicate function. */ export const filter = ( From 18704d9231221440ce11aef6af104fd54b05983f Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 6 Jun 2024 16:25:49 -0700 Subject: [PATCH 29/30] Remove index (not used rn) --- sketches/2024-05-30-common-frp/src/index.ts | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 sketches/2024-05-30-common-frp/src/index.ts diff --git a/sketches/2024-05-30-common-frp/src/index.ts b/sketches/2024-05-30-common-frp/src/index.ts deleted file mode 100644 index 8deebcd59..000000000 --- a/sketches/2024-05-30-common-frp/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * as stream from './stream' -export * as signal from './signal' -export {config} from './shared' \ No newline at end of file From 9a319a9624a82bab3361bc4b9e094cbb0c60f76f Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 6 Jun 2024 16:25:58 -0700 Subject: [PATCH 30/30] Rename publisher --- sketches/2024-05-30-common-frp/src/publisher.ts | 2 +- sketches/2024-05-30-common-frp/src/signal.ts | 6 +++--- sketches/2024-05-30-common-frp/src/stream.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sketches/2024-05-30-common-frp/src/publisher.ts b/sketches/2024-05-30-common-frp/src/publisher.ts index eaf8f9896..d9d77f7aa 100644 --- a/sketches/2024-05-30-common-frp/src/publisher.ts +++ b/sketches/2024-05-30-common-frp/src/publisher.ts @@ -9,7 +9,7 @@ export type Cancellable = { export type Send = (value: T) => void /** Low-level pub-sub channel used under the hood by cells and sinks. */ -export const createPublisher = () => { +export const publisher = () => { const subscribers = new Set>() const pub = (value: T) => { diff --git a/sketches/2024-05-30-common-frp/src/signal.ts b/sketches/2024-05-30-common-frp/src/signal.ts index 821e81363..44d565a92 100644 --- a/sketches/2024-05-30-common-frp/src/signal.ts +++ b/sketches/2024-05-30-common-frp/src/signal.ts @@ -1,6 +1,6 @@ import { debug } from './shared' import { - createPublisher, + publisher, Send, Cancel, Cancellable, @@ -93,7 +93,7 @@ export const effect = ( } export const signal = (initial: T) => { - const updates = createPublisher() + const updates = publisher() let state = initial @@ -181,7 +181,7 @@ export const computed: computed = ( upstreams: Array>, compute: (...values: Array) => any ): Signal => { - const updates = createPublisher() + const updates = publisher() const recompute = () => compute(...upstreams.map(sample)) diff --git a/sketches/2024-05-30-common-frp/src/stream.ts b/sketches/2024-05-30-common-frp/src/stream.ts index ad8f16a79..5b5dc23a8 100644 --- a/sketches/2024-05-30-common-frp/src/stream.ts +++ b/sketches/2024-05-30-common-frp/src/stream.ts @@ -1,5 +1,5 @@ import { debug } from "./shared" -import { createPublisher, Send, Cancel, combineCancels } from "./publisher" +import { publisher, Send, Cancel, combineCancels } from "./publisher" import { signal as signal, Signal, __updates__ } from "./signal" const __sink__ = Symbol('sink') @@ -22,7 +22,7 @@ export const sink = ( export const stream = ( generate: (send: Send) => Cancel|undefined ): Stream => { - const {pub, sub} = createPublisher() + const {pub, sub} = publisher() const cancel = generate(pub) return {[__sink__]: sub, cancel} }