diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b35f734..209024a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,5 +23,5 @@ jobs: - run: npm ci - run: npm run build -s - run: npm test - - run: npm publish --ignore-scripts --tag latest + - run: npm publish --ignore-scripts --tag next diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b092fd..f2e2ee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,39 @@ +
+ +## v0.4 + +### v0.4.0 +- 🟥 **huge core rewrite** + - 🟥 everything is now sync, not async anymore. stripped of all debouncing and async shenanigans, now calling `tracker.write` (and thus setting signals, updating prism state, etc) immediately executes all downstream subscribers without any delay. this greatly improves our ability to detect and prevent scary catastropic circular-loop crashes. this also avoids async fatigue spreading through your codebases. the downside is that this could lead to worse performance. + - 🍏 new `batch` fn helps you optimize performance -- batched tracker writes are deduped and flushed at the end of the batch, meaning, effects are only called once. +- 🟥 **tracker** + - 🟥 ⚠️⚠️ global symbol changed from `e280.tracker` to `e280.tracker.2` -- this means strata v0.3 and v0.4 are treated like totally different state management libraries, they are FULLY incompatible, eg, if you have one dependency on strata 0.3 and another on 0.4, the `effect` from one will be blind to signals from the other. + - 🟥 renamed `tracker.notifyRead` to `tracker.read` + - 🟥 renamed `tracker.notifyWrite` to `tracker.write` +- 🟥 **signals/derived/effects** + - 🟥 eliminated all the funky magic class+fn implementations for dead-simple minimal implementations... new signal module is 18 lines... + - 🟥 removed `$count.on` direct subscriptions -- just use effects + - 🟥 removed `$count.value` accessors -- just use hipster-fn syntax + - 🟥 removed `$count.get()` and `$count.set(v)` methods -- just use hipster-fn syntax + - 🟥 removed comparison logic, now all signal value setting always notifies the tracker, doesn't care if there was a real change + - 🟥 removed `lazy` completely removed -- obsoleted by superior new derived implementation that is lazy + - 🟥 `effect` is now simpler and only takes one simple callback fn + - 🟥 renamed type `Signaly` / `SignalyFn` to `Valuable` + - 🍏 new `afterEffect` takes a collector fn and responder fn like the old `effect` did +- 🟥 **wait** + - 🟥 renamed `WaitDone` to `WaitResult` to better match ok/err/result + - 🟥 renamed `newWait` to `makeWait` because i like it more + - 🟥 renamed `WaitSignal` to `Waiter` + - 🟥 renamed `waitResult` to `waitFormal` because i said so +- 🟥 **react bindings** + - 🟥 renamed `react` fn to `reactBindings` + - 🟥 renamed `useStrata` hook to `useTracked` + + +
## v0.3 diff --git a/README.md b/README.md index 77293fa..46ec4f3 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ 🚦 [**#signals,**](#signals) sweet little bundles of state 🔮 [**#prism,**](#prism) bigger centralized state trees -⌛ [**#wait,**](#wait) representing async operations +⌛ [**#wait,**](#wait) async state helpers *(think loading spinners)* 🪄 [**#tracker,**](#tracker) agnostic reactivity integration hub ⚛️ [**#react,**](#react) optional bindings for react @@ -30,101 +30,70 @@ > *reactive bundles of joy* ```ts -import {signal, effect, derived, lazy} from "@e280/strata" +import {signal, derived, effect, batch} from "@e280/strata" ``` -### 🚦 each signal holds a value -- **make signal** +### 🚦 signal +- **a signal holds a value** ```ts - const $count = signal(0) + const $count = signal(1) ``` - > *maybe you like the `$` prefix convention for signals?* -- **read signal** + > *we kinda like the `$` convention for signals* +- **read the signal** ```ts - $count() // 0 + $count() // 1 ``` -- **write signal** +- **write the signal** ```ts - $count(1) + $count(2) ``` -- 🤯 **await all downstream effects** - ```ts - await $count(2) - ``` - > *this is supposed to impress you* -### 🚦 pick your poison -- **signal hipster-fn syntax** - ```ts - $count() // read - await $count(2) // write - ``` -- **signal get/set syntax** +### 🚦 derived +- **combine signals like a formula** ```ts - $count.get() // read - await $count.set(2) // write + const $alpha = signal(1) + const $bravo = signal(10) + const $product = derived(() => $alpha() * $bravo()) ``` -- **signal .value accessor syntax** +- **it automatically updates** ```ts - $count.value // read - $count.value = 2 // write - ``` - value pattern is super nice for these vibes - ```ts - $count.value++ - $count.value += 1 + $product() // 10 + $alpha(2) + $product() // 🪄 20 ``` +- **btw,** + deriveds are lazy, only run the fn when demanded. + also, they have a `.dispose()` fn if you need to stop them. ### 🚦 effects -- **effects run when the relevant signals change** +- **effects run immediately, then again when relevant signals change** ```ts effect(() => console.log($count())) - // 1 + // 2 // the system detects '$count' is relevant - $count.value++ - // 2 + $count(3) + // 3 // when $count is changed, the effect fn is run ``` +- **btw,** + effect returns a dispose fn if you need to stop it. -### 🚦 .on listeners -- **yes, you can do direct callbacks to listen for changes** - ```ts - const off = $count.on(count => console.log(`callback ${count}`)) - - $count.value++ - // "callback 3" - - off() - // stop listening - ``` - -### 🚦 derived signals -- **derived,** for combining signals, like a formula +### 🚦 batch +- **optimize multiple writes into one fat update** ```ts - const $a = signal(1) - const $b = signal(10) - const $product = derived(() => $a() * $b()) - - $product() // 10 - - // change a dependency, - // and the derived signal is automatically updated - await $a(2) - - $product() // 20 + // call downstream effects only once + batch(() => { + $count(4) + $count(5) + $count(6) + }) ``` -- **lazy,** for making special optimizations. - it's like derived, except it cannot trigger effects, - because it's so damned lazy, it only computes the value on read, and only when necessary. - > *i repeat: lazy signals cannot trigger effects!* -### 🚦 types and such -- **`Signaly`** — can be `Signal` or `Derived` or `Lazy` - - these are types for the core primitive classes -- **the classes are funky** - - Signal, Derived, and Lazy classes cannot be subclassed or extended, due to spooky magic we've done to make the instances callable as functions (hipster syntax). - - however, at least `$count instanceof Signal` works, so at least that's working. +### 🚦 types +- **`Signal`** — it's a signal fn +- **`Derived`** — it's a derived fn +- **`Valuable`** — could be `Signal` or `Derived` @@ -166,16 +135,21 @@ import {signal, effect, derived, lazy} from "@e280/strata" const person = snacks.lens(state => state.person) ``` - you can lens another lens -- **lenses provide snapshot access to state** +- **`lens.state` is a cloned mutable snapshot with chill typings** ```ts - // .state is a mutable snapshot with relaxed typings snacks.state.peanuts // 8 person.state.name // "chase" - // .frozen is an immutable snapshot with strict typings - snacks.frozen.peanuts // 8 + snacks.state.peanuts++ + // ⛔ attempted state mutation: silently ignored + ``` +- **`lens.frozen` provides a deep-frozen immutable snapshot with strict typings** + ```ts + snacks.frozen.peanuts // 8 (readonly) + person.frozen.name // "chase" (readonly) + snacks.frozen.peanuts++ - // ⛔ error: casual mutations forbidden + // ⛔ attempted frozen mutation: throw errors ``` - **only formal mutations can actually change state** ```ts @@ -186,7 +160,7 @@ import {signal, effect, derived, lazy} from "@e280/strata" ``` - **array mutations are unironically based, actually** ```ts - await snacks.mutate(state => state.bag.push("salt")) + snacks.mutate(state => state.bag.push("salt")) ``` ### 🔮 chrono for time travel @@ -269,99 +243,74 @@ import {signal, effect, derived, lazy} from "@e280/strata" ## 🍋 strata wait -> *represent async operations* - -- wait is designed to vibe with [stz#ok](https://github.com/e280/stz#ok) - ```ts - import {ok, err} from "@e280/stz" - ``` - -### ⌛ wait primitives -- imports - ```ts - import {newWait} from "@e280/strata" - ``` -- helpers to create a `Wait` - ```ts - // loading - newWait() - // {done: false} - - // done, ok - newWait(ok(123)) - // {done: true, ok: true, value: 123} +> *tiny async state helpers* - // done, err - newWait(err("uh oh")) - // {done: true, ok: false, error: "uh oh"} - ``` +it's about states like *pending, ok, err.* +wait extends [stz's ok/err](https://github.com/e280/stz#ok) toolkit, and it's mostly for showing little loading spinners in your ui. -### ⌛ make a magic reactive wait signal -- imports +### ⌛ good things come to those who wait +- **import stuff** ```ts - import {nap} from "@e280/stz" - import {wait} from "@e280/strata" + import {ok, err, nap} from "@e280/stz" + import {wait, waitFormal} from "@e280/strata" ``` -- it's a derived signal (readonly) that tracks the result of an async fn or promise +- **wrap any async operation and get a "Waiter"** ```ts + // wrap any async operation in a fancy wait const $wait = wait(async() => { - await nap(100) // do some async stuff - return 123 // return your value + await nap(100) + if (Math.random() > 0.5) return 123 + else throw new Error("bad luck!") }) ``` -- read the current state from the signal + - btw you can pass a promise instead of an async fn +- **check if it's done** ```ts - $wait() - // {done: false} + console.log($wait().done) + // false -- sorry bro, its not ready yet ``` -- later, when it's ready +- **okay, we can actually await for the result** ```ts - await $wait.ready - // 123 - // undefined if there was an error + const result = await $wait.result - $wait() - // {done: true, ok: true, value: 123} + if (result.ok) + console.log(result.value) + // 123 + else + console.error(result.error) + // Error: bad luck! ``` -### ⌛ persnickety belt-and-suspenders mode -- imports +### ⌛ waitFormal is persnickety belt-and-suspenders mode +- **you can get super explicit about the types** ```ts - import {waitResult} from "@e280/strata" - ``` -- do formal rigid error handling because you're super strict and serious - ```ts - const $wait = waitResult(async() => { + const $wait = waitFormal(async() => { if (Math.random() > 0.5) return ok(123) if (Math.random() < 0.01) - return err("unlikely lol") + return err("unlucky") else return err("bad roll") }) ``` -- listen for the formal result - ```ts - await $wait.result - // {done: true, ok: true, value: 123} or - // {done: true, ok: false, error: "bad roll"} - ``` -- btw, wait and waitResult will actually accept a promise if you like + +### ⌛ wait, there's more +- maker ```ts - const $wait = wait(Promise.resolve(123)) + makeWait() // pending + makeWait(ok(123)) + makeWait(err("uh oh")) ``` - -### ⌛ wait helpers -- check the state +- status checkers ```ts isWaitPending($wait()) - isWaitDone($wait()) + isWaitDone($wait()) // ok or err isWaitOk($wait()) isWaitErr($wait()) ``` -- get the finished value or error +- value grabbers ```ts waitGetOk($wait()) // 123 | undefined waitNeedOk($wait()) // 123 (or throws an error) @@ -370,7 +319,7 @@ import {signal, effect, derived, lazy} from "@e280/strata" waitGetErr($wait()) // "bad roll" | undefined waitNeedErr($wait()) // "bad roll" (or throws an error) ``` -- select based on the state +- quick selector ```ts const text = waitSelect($wait(), { pending: () => "still loading...", @@ -392,71 +341,40 @@ import {signal, effect, derived, lazy} from "@e280/strata" import {tracker} from "@e280/strata/tracker" ``` -if you're some kinda framework author, making a new ui thing, or a new state concept -- then you can use the `tracker` to jack into the strata reactivity system, and suddenly your stuff will be fully strata-compatible, reactin' and triggerin' with the best of 'em. - -the tracker is agnostic and independent, and doesn't know about strata specifics like signals or trees -- and it would be perfectly reasonable for you to use strata solely to integrate with the tracker, thus making your stuff reactivity-compatible with other libraries that use the tracker, like [sly](https://github.com/e280/sly). - -note, the *items* that the tracker tracks can be any object, or symbol.. the tracker cares about the identity of the item, not the value (tracker holds them in a WeakMap to avoid creating a memory leak).. - -### 🪄 integrate your ui's reactivity -- we need to imagine you have some prerequisites - - `myRenderFn` -- your fn that might access some state stuff - - `myRerenderFn` -- your fn that is called when some state stuff changes - - it's okay if these are the same fn, but they don't have to be -- `tracker.observe` to check what is touched by a fn - ```ts - // 🪄 run myRenderFn and collect seen items - const {seen, result} = tracker.observe(myRenderFn) - - // a set of items that were accessed during myRenderFn - seen - - // the value returned by myRenderFn - result - ``` -- it's a good idea to debounce your rerender fn - ```ts - import {microbounce} from "@e280/stz" - const myDebouncedRerenderFn = microbounce(myRerenderFn) - ``` -- `tracker.subscribe` to respond to changes - ```ts - const stoppers: (() => void)[] = [] - - // loop over every seen item - for (const item of seen) { +this is the inner sanctum of strata. use the tracker to jack into the reactivity system, you can make anything fully strata-compatible and you'll be reactin' and triggerin' with the best of 'em. the tracker is also what you'll need if you're trying to create bindings for your own frontend framework to trigger your ui to rerender and stuff. - // 🪄 react to changes - const stop = tracker.subscribe(item, myDebouncedRerenderFn) +### 🪄 invent your own novel state concept +- let's invent a very simple thing, so you can see how simple the tracker really is. + ```ts + export class BoomerSignal { + constructor(private value: Value) {} - stoppers.push(stop) + get() { + tracker.read(this) // 🪄 inform tracker our thing was accessed + return this.value } + + set(value: Value) { + this.value = value + tracker.write(this) // 🪄 inform tracker our thing was changed + } + } + ``` +- boom, that's it! now we have a new reactive thing we can use, and it'll rerender our ui or whatever. + ```ts + const $count = new BoomerSignal(1) - const stopReactivity = () => stoppers.forEach(stop => stop()) - ``` - -### 🪄 integrate your own novel state concepts -- as an example, we'll invent the simplest possible signal - ```ts - export class SimpleSignal { - constructor(private value: Value) {} - - get() { - - // 🪄 tell the tracker this signal was accessed - tracker.notifyRead(this) - - return this.value - } + effect(() => console.log($count.get())) + // 1 - async set(value: Value) { - this.value = value + $count.set(2) + // 2 + ``` - // 🪄 tell the tracker this signal has changed - await tracker.notifyWrite(this) - } - } - ``` +### 🪄 integrate your ui framework for auto-rerendering +- use `tracker.observe` to check what is touched by a fn +- use `tracker.subscribe` to subscribe to the seen items that `observe` returns +- see the [source code](./tracker/tracker.ts) @@ -469,49 +387,51 @@ note, the *items* that the tracker tracks can be any object, or symbol.. the tra ### ⚛️ setup your `strata.ts` module ```ts import * as react from "react" -import {react as strata} from "@e280/strata" +import {reactBindings} from "@e280/strata" export const { component, - useStrata, + useTracked, useOnce, useSignal, useDerived, -} = strata(react) +} = reactBindings(react) ``` ### ⚛️ `component` enables fully automatic reactive re-rendering ```ts +import {signal} from "@e280/strata" import {component} from "./strata.js" const $count = signal(0) export const MyCounter = component(() => { - const add = () => $count.value++ + const add = () => $count($count() + 1) return }) ``` -### ⚛️ `useStrata` for a manual hands-on approach (plays nicer with hmr) +### ⚛️ `useTracked` for a manual hands-on approach (plays nicer with hmr) ```ts -import {useStrata} from "./strata.js" +import {signal} from "@e280/strata" +import {useTracked} from "./strata.js" const $count = signal(0) export const MyCounter = () => { - const count = useStrata(() => $count()) - const add = () => $count.value++ + const count = useTracked(() => $count()) + const add = () => $count($count() + 1) return } ``` -### ⚛️ `useSignal` for local component state +### ⚛️ `useSignal` for local component state (and `useDerived`) ```ts import {useSignal} from "./strata.js" export const MyCounter = () => { const $count = useSignal(0) - const add = () => $count.value++ + const add = () => $count($count() + 1) return } ``` diff --git a/package-lock.json b/package-lock.json index 2462eb0..36b8381 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,20 @@ { "name": "@e280/strata", - "version": "0.3.5", + "version": "0.4.0-next.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@e280/strata", - "version": "0.3.5", + "version": "0.4.0-next.8", "license": "MIT", "dependencies": { - "@e280/stz": "^0.2.35" + "@e280/stz": "0.3.0-next.1" }, "devDependencies": { "@e280/octo": "^0.1.1", "@e280/science": "^0.1.11", - "@types/node": "^25.6.0", + "@types/node": "^25.8.0", "typescript": "^6.0.3" } }, @@ -47,6 +47,13 @@ "octo-sequence": "x/octo-sequence.js" } }, + "node_modules/@e280/octo/node_modules/@e280/stz": { + "version": "0.2.36", + "resolved": "https://registry.npmjs.org/@e280/stz/-/stz-0.2.36.tgz", + "integrity": "sha512-lA+CqHdWabjUEpxFzYfag8Pb76EM4RfRyK+PWqYL0gkrhHxCkAcjESWKioAwXHQiJB/cdiIlYWSLc6qcIL0WIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@e280/science": { "version": "0.1.11", "resolved": "https://registry.npmjs.org/@e280/science/-/science-0.1.11.tgz", @@ -71,20 +78,27 @@ "@e280/stz": "^0.2.34" } }, + "node_modules/@e280/strata/node_modules/@e280/stz": { + "version": "0.2.36", + "resolved": "https://registry.npmjs.org/@e280/stz/-/stz-0.2.36.tgz", + "integrity": "sha512-lA+CqHdWabjUEpxFzYfag8Pb76EM4RfRyK+PWqYL0gkrhHxCkAcjESWKioAwXHQiJB/cdiIlYWSLc6qcIL0WIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@e280/stz": { - "version": "0.2.35", - "resolved": "https://registry.npmjs.org/@e280/stz/-/stz-0.2.35.tgz", - "integrity": "sha512-7UoJkrd/FvN6j/F9xn90sZo+nz63CH3FYzxGd4VTmOFOL7RkApTHni/vuIUvO1IBP1iWrp06VAoHWMhkO54PQg==", + "version": "0.3.0-next.1", + "resolved": "https://registry.npmjs.org/@e280/stz/-/stz-0.3.0-next.1.tgz", + "integrity": "sha512-QDwen0GyDITbOiZSUKcpAdrLqdJ/qr5UGkzOff+K0aKSTp57UmDfFzH6LLD2Hhddf//GRBBoX8tU2BWUuMYzRw==", "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/ansi-regex": { @@ -174,9 +188,9 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" } diff --git a/package.json b/package.json index 5a4b492..756da4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@e280/strata", - "version": "0.3.5", + "version": "0.4.0-next.8", "description": "state management", "license": "MIT", "author": "Chase Moskal ", @@ -12,6 +12,7 @@ ], "exports": { ".": "./x/index.js", + "./bindings": "./x/bindings/index.js", "./prism": "./x/prism/index.js", "./signals": "./x/signals/index.js", "./tracker": "./x/tracker/index.js", @@ -24,12 +25,12 @@ "count": "find s -path '*/_archive' -prune -o -name '*.ts' -exec wc -l {} +" }, "dependencies": { - "@e280/stz": "^0.2.35" + "@e280/stz": "0.3.0-next.1" }, "devDependencies": { "@e280/octo": "^0.1.1", "@e280/science": "^0.1.11", - "@types/node": "^25.6.0", + "@types/node": "^25.8.0", "typescript": "^6.0.3" }, "keywords": [ diff --git a/s/bindings/index.ts b/s/bindings/index.ts new file mode 100644 index 0000000..cc870e2 --- /dev/null +++ b/s/bindings/index.ts @@ -0,0 +1,3 @@ + +export * from "./react.js" + diff --git a/s/bindings/react.ts b/s/bindings/react.ts new file mode 100644 index 0000000..8b77e58 --- /dev/null +++ b/s/bindings/react.ts @@ -0,0 +1,52 @@ + +import {signal} from "../signals/signal.js" +import {tracker} from "../tracker/global.js" +import {derived} from "../signals/derived.js" + +export function reactBindings(react: { + useEffect: (fn: () => void | (() => void), deps?: unknown[]) => void + useState: (x: X | (() => X)) => [ + value: X, + set: (value: X | ((x: X) => X)) => void + ] + }) { + + const useTracked = (fn: () => X) => { + const [, setTick] = react.useState(0) + const {seen, value} = tracker.observe(fn) + + react.useEffect(() => { + const rerender = () => setTick(tick => tick + 1) + const stoppers = [...seen].map(item => tracker.subscribe(item, rerender)) + return () => stoppers.forEach(stop => stop()) + }) + + return value + } + + const component =

(render: (props: P) => R) => { + const c = (props: P) => useTracked(() => render(props)) + c.displayName = (render as any).displayName ?? render.name ?? "Component" + return c + } + + const useOnce = (fn: () => X) => { + const [value] = react.useState(fn) + return value + } + + const useSignal = (value: X) => { + const $signal = useOnce(() => signal(value)) + void useTracked(() => $signal()) + return $signal + } + + const useDerived = (formula: () => X) => { + const $derived = useOnce(() => derived(formula)) + void useTracked(() => $derived()) + return $derived + } + + return {component, useTracked, useOnce, useSignal, useDerived} +} + diff --git a/s/index.ts b/s/index.ts index 305e513..9c494d3 100644 --- a/s/index.ts +++ b/s/index.ts @@ -1,6 +1,7 @@ -export * from "./prism/index.js" +export * from "./bindings/index.js" export * from "./signals/index.js" +export * from "./prism/index.js" export * from "./tracker/index.js" export * from "./wait/index.js" diff --git a/s/prism/chrono/chrono.ts b/s/prism/chrono/chrono.ts index e1fc313..449c2ab 100644 --- a/s/prism/chrono/chrono.ts +++ b/s/prism/chrono/chrono.ts @@ -42,13 +42,13 @@ export class Chrono implements LensLike { } /** progress forwards, depositing history into the past */ - async mutate(fn: (state: State) => R): Promise { + mutate(fn: (state: State) => R): R { return this.basis.mutate(chronicle => this.#mut(chronicle, fn)) } /** step backwards into the past, by n steps */ - async undo(n = 1) { - await this.basis.mutate(chronicle => { + undo(n = 1) { + this.basis.mutate(chronicle => { const snapshots = chronicle.past.slice(-n) if (snapshots.length >= n) { const oldPresent = chronicle.present @@ -60,8 +60,8 @@ export class Chrono implements LensLike { } /** step forwards into the future, by n steps */ - async redo(n = 1) { - await this.basis.mutate(chronicle => { + redo(n = 1) { + this.basis.mutate(chronicle => { const snapshots = chronicle.future.slice(0, n) if (snapshots.length >= n) { const oldPresent = chronicle.present @@ -73,8 +73,8 @@ export class Chrono implements LensLike { } /** wipe past and future snapshots */ - async wipe() { - await this.basis.mutate(chronicle => { + wipe() { + this.basis.mutate(chronicle => { chronicle.past = [] chronicle.future = [] }) diff --git a/s/prism/lens.ts b/s/prism/lens.ts index 9ec7c56..98725c5 100644 --- a/s/prism/lens.ts +++ b/s/prism/lens.ts @@ -1,24 +1,17 @@ -import {deep, microbounce, sub} from "@e280/stz" +import {deep} from "@e280/stz" import {immute} from "./utils/immute.js" -import {tracker} from "../tracker/tracker.js" +import {tracker} from "../tracker/global.js" import {_optic} from "./utils/optic-symbol.js" import {CacheCell} from "./utils/cache-cell.js" import {Immutable, LensLike, Optic} from "./types.js" /** reactive view into a state prism, with formalized mutations */ export class Lens implements LensLike { - on = sub<[state: State]>() - onFrozen = sub<[state: Immutable]>() - ;[_optic]: Optic #previous: State #stateCache: CacheCell #frozenCache: CacheCell> - #onPublishDebounced = microbounce(() => { - this.on.publish(this.state) - this.onFrozen.publish(this.frozen) - }) constructor(optic: Optic) { this[_optic] = optic @@ -27,31 +20,30 @@ export class Lens implements LensLike { this.#frozenCache = new CacheCell(() => immute(optic.getState())) } - async update() { + update() { const state = this[_optic].getState() const isChanged = !deep.equal(state, this.#previous) if (isChanged) { this.#stateCache.invalidate() this.#frozenCache.invalidate() this.#previous = deep.clone(state) - this.#onPublishDebounced() - await tracker.notifyWrite(this) + tracker.write(this) } } /** get a snapshot of the current state. it's typed as mutable, but you should not mutate it. */ get state() { - tracker.notifyRead(this) + tracker.read(this) return this.#stateCache.get() } /** get an immutable readonly snapshot of the current state. */ get frozen() { - tracker.notifyRead(this) + tracker.read(this) return this.#frozenCache.get() } - async mutate(fn: (state: State) => R) { + mutate(fn: (state: State) => R) { return this[_optic].mutate(fn) } diff --git a/s/prism/prism.ts b/s/prism/prism.ts index a220d40..a00b614 100644 --- a/s/prism/prism.ts +++ b/s/prism/prism.ts @@ -1,38 +1,33 @@ -import {microbounce, sub} from "@e280/stz" import {Lens} from "./lens.js" -import {tracker} from "../tracker/tracker.js" +import {tracker} from "../tracker/global.js" /** state mangagement source-of-truth */ export class Prism { #state: State #lenses = new Set>() - on = sub<[state: State]>() - #onPublishDebounced = microbounce(() => this.on.publish(this.#state)) - constructor(state: State) { this.#state = state } get() { - tracker.notifyRead(this) + tracker.read(this) return this.#state } - async set(state: State) { + set(state: State) { this.#state = state - await Promise.all([...this.#lenses].map(lens => lens.update())) - await this.#onPublishDebounced() - await tracker.notifyWrite(this) + for (const lens of this.#lenses) lens.update() + tracker.write(this) } lens(selector: (state: State) => State2) { const lens = new Lens({ getState: () => selector(this.#state), - mutate: async fn => { + mutate: fn => { const result = fn(selector(this.#state)) - await this.set(this.#state) + this.set(this.#state) return result }, registerLens: lens => this.#lenses.add(lens), diff --git a/s/prism/test.ts b/s/prism/test.ts index 184d76b..0e1f4d0 100644 --- a/s/prism/test.ts +++ b/s/prism/test.ts @@ -2,30 +2,30 @@ import {suite, test, expect} from "@e280/science" import {Prism} from "./prism.js" +import {effect} from "../signals/effect.js" import {Chrono} from "./chrono/chrono.js" import {chronicle} from "./chrono/chronicle.js" -import {effect} from "../signals/effect/effect.js" +import { batch } from "../signals/batch.js" export default suite({ "prism": suite({ "get/set state": test(async() => { const prism = new Prism({count: 1}) expect(prism.get().count).is(1) - await prism.set({count: 2}) + prism.set({count: 2}) expect(prism.get().count).is(2) }), "get/set state can trigger effects": test(async() => { const prism = new Prism({count: 1}) let triggered = 0 - const stop = effect(() => { + effect(() => { void prism.get().count triggered++ }) expect(triggered).is(1) - await prism.set({count: 2}) + prism.set({count: 2}) expect(triggered).is(2) - stop() }), }), @@ -45,16 +45,16 @@ export default suite({ "proper mutation": test(async() => { const prism = new Prism({data: {count: 1}}) const lens = prism.lens(s => s.data) - await lens.mutate(s => s.count++) + lens.mutate(s => s.count++) expect(lens.frozen.count).is(2) - await lens.mutate(s => s.count++) + lens.mutate(s => s.count++) expect(lens.frozen.count).is(3) }), "state after mutation is frozen": test(async() => { const prism = new Prism({data: {count: 1}}) const lens = prism.lens(s => s) - await lens.mutate(s => s.data = {count: 2}) + lens.mutate(s => s.data = {count: 2}) expect(lens.frozen.data.count).is(2) expect(() => (lens.frozen.data as any).count++).throws() }), @@ -63,48 +63,49 @@ export default suite({ const prism = new Prism({data: {count: 1}}) const lens = prism.lens(s => s.data) let happenings = 0 - const stop = effect(() => { + effect(() => { void lens.frozen.count happenings++ }) - await lens.mutate(s => s.count++) + lens.mutate(s => s.count++) expect(happenings).is(2) - stop() }), - "lens.on is debounced": test(async() => { + "effects can be batched": test(async() => { const prism = new Prism({data: {count: 1}}) const lens = prism.lens(s => s.data) let happenings = 0 - const stop = lens.on(() => void happenings++) - await Promise.all([ - lens.mutate(s => s.count++), - lens.mutate(s => s.count++), - ]) - expect(happenings).is(1) - stop() + effect(() => { + void lens.frozen.count + happenings++ + }) + batch(() => { + lens.mutate(s => s.count++) + lens.mutate(s => s.count++) + }) + expect(happenings).is(2) }), "array pushes are reactive": test(async() => { const prism = new Prism({data: {array: ["lol"]}}) const lens = prism.lens(s => s.data) let happenings = 0 - const stop = lens.on(() => void happenings++) - await lens.mutate(s => s.array.push("lmao")) - expect(happenings).is(1) + effect(() => { + void lens.state + happenings++ + }) + lens.mutate(s => s.array.push("lmao")) + expect(happenings).is(2) expect(lens.frozen.array.length).is(2) - stop() }), "sync coherence": test(async() => { const prism = new Prism({data: {count: 1}}) const lens = prism.lens(s => s.data) - const p1 = lens.mutate(s => s.count++) + lens.mutate(s => s.count++) expect(lens.frozen.count).is(2) - const p2 = lens.mutate(s => s.count++) + lens.mutate(s => s.count++) expect(lens.frozen.count).is(3) - await p1 - await p2 }), "nullable selector": test(async() => { @@ -112,7 +113,7 @@ export default suite({ const prism = new Prism({a: {b: {count: 1}}}) const lens = prism.lens(s => s.a?.b) expect(lens.frozen?.count).is(1) - await prism.set({a: undefined}) + prism.set({a: undefined}) expect(lens.frozen?.count).is(undefined) }), @@ -129,11 +130,11 @@ export default suite({ const prism = new Prism({a: {b: {count: 1}}}) const lensA = prism.lens(s => s.a) const lensB = lensA.lens(s => s.b) - await lensB.mutate(s => s.count++) + lensB.mutate(s => s.count++) expect(prism.get().a.b.count).is(2) expect(lensA.frozen.b.count).is(2) expect(lensB.frozen.count).is(2) - await lensA.mutate(s => s.b = {count: 3}) + lensA.mutate(s => s.b = {count: 3}) expect(prism.get().a.b.count).is(3) expect(lensA.frozen.b.count).is(3) expect(lensB.frozen.count).is(3) @@ -145,13 +146,17 @@ export default suite({ const lensB = prism.lens(s => s.b) let happeningsA = 0 let happeningsB = 0 - const stopA = lensA.on(() => void happeningsA++) - const stopB = lensB.on(() => void happeningsA++) - await lensA.mutate(s => s.count++) - expect(happeningsA).is(1) - expect(happeningsB).is(0) - stopA() - stopB() + effect(() => { + void lensA.state + happeningsA++ + }) + effect(() => { + void lensB.state + happeningsB++ + }) + lensA.mutate(s => s.count++) + expect(happeningsA).is(2) + expect(happeningsB).is(1) }), "outside mutations ignored for effects": test(async() => { @@ -160,19 +165,17 @@ export default suite({ const lensB = prism.lens(s => s.b) let happeningsA = 0 let happeningsB = 0 - const stopA = effect(() => { + effect(() => { void lensA.frozen.count happeningsA++ }) - const stopB = effect(() => { + effect(() => { void lensB.frozen.count happeningsB++ }) - await lensA.mutate(s => s.count++) + lensA.mutate(s => s.count++) expect(happeningsA).is(2) expect(happeningsB).is(1) - stopA() - stopB() }), }), @@ -188,9 +191,9 @@ export default suite({ const prism = new Prism({data: chronicle({count: 1})}) const lens = prism.lens(s => s.data) const chrono = new Chrono(64, lens) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.frozen.count).is(2) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.frozen.count).is(3) }), @@ -200,17 +203,17 @@ export default suite({ const chrono = new Chrono(64, lens) expect(chrono.undoable).is(0) expect(chrono.redoable).is(0) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.undoable).is(1) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.undoable).is(2) - await chrono.undo() + chrono.undo() expect(chrono.undoable).is(1) expect(chrono.redoable).is(1) - await chrono.undo() + chrono.undo() expect(chrono.undoable).is(0) expect(chrono.redoable).is(2) - await chrono.redo() + chrono.redo() expect(chrono.undoable).is(1) expect(chrono.redoable).is(1) }), @@ -220,10 +223,10 @@ export default suite({ const lens = prism.lens(s => s.data) const chrono = new Chrono(64, lens) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.frozen.count).is(2) - await chrono.undo() + chrono.undo() expect(chrono.frozen.count).is(1) }), @@ -244,13 +247,13 @@ export default suite({ const lens = prism.lens(s => s.data) const chrono = new Chrono(64, lens) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.frozen.count).is(2) - await chrono.undo() + chrono.undo() expect(chrono.frozen.count).is(1) - await chrono.redo() + chrono.redo() expect(chrono.frozen.count).is(2) }), @@ -259,30 +262,30 @@ export default suite({ const lens = prism.lens(s => s.data) const chrono = new Chrono(64, lens) - await chrono.mutate(s => s.count++) - await chrono.mutate(s => s.count++) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.frozen.count).is(4) - await chrono.undo() + chrono.undo() expect(chrono.frozen.count).is(3) - await chrono.undo() + chrono.undo() expect(chrono.frozen.count).is(2) - await chrono.redo() + chrono.redo() expect(chrono.frozen.count).is(3) - await chrono.redo() + chrono.redo() expect(chrono.frozen.count).is(4) - await chrono.undo() + chrono.undo() expect(chrono.frozen.count).is(3) - await chrono.undo() + chrono.undo() expect(chrono.frozen.count).is(2) - await chrono.undo() + chrono.undo() expect(chrono.frozen.count).is(1) }), @@ -290,7 +293,7 @@ export default suite({ const prism = new Prism({data: chronicle({count: 1})}) const lens = prism.lens(s => s.data) const chrono = new Chrono(64, lens) - await chrono.undo() + chrono.undo() expect(chrono.frozen.count).is(1) }), @@ -298,7 +301,7 @@ export default suite({ const prism = new Prism({data: chronicle({count: 1})}) const lens = prism.lens(s => s.data) const chrono = new Chrono(64, lens) - await chrono.redo() + chrono.redo() expect(chrono.frozen.count).is(1) }), @@ -306,11 +309,11 @@ export default suite({ const prism = new Prism({data: chronicle({count: 1})}) const lens = prism.lens(s => s.data) const chrono = new Chrono(64, lens) - await chrono.mutate(s => s.count++) - await chrono.mutate(s => s.count++) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.frozen.count).is(4) - await chrono.undo(2) + chrono.undo(2) expect(chrono.frozen.count).is(2) }), @@ -318,13 +321,13 @@ export default suite({ const prism = new Prism({data: chronicle({count: 1})}) const lens = prism.lens(s => s.data) const chrono = new Chrono(64, lens) - await chrono.mutate(s => s.count++) - await chrono.mutate(s => s.count++) - await chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) expect(chrono.frozen.count).is(4) - await chrono.undo(2) + chrono.undo(2) expect(chrono.frozen.count).is(2) - await chrono.redo(2) + chrono.redo(2) expect(chrono.frozen.count).is(4) }), @@ -333,9 +336,9 @@ export default suite({ const chrono = new Chrono(64, prism.lens(s => s.data)) const sublens = chrono.lens(s => s.a) expect(sublens.frozen.count).is(1) - await sublens.mutate(s => s.count++) + sublens.mutate(s => s.count++) expect(sublens.frozen.count).is(2) - await chrono.undo() + chrono.undo() expect(sublens.frozen.count).is(1) }), }), diff --git a/s/prism/types.ts b/s/prism/types.ts index 2d6a31a..0f00556 100644 --- a/s/prism/types.ts +++ b/s/prism/types.ts @@ -4,13 +4,13 @@ import {Lens} from "./lens.js" export type LensLike = { readonly state: State readonly frozen: Immutable - mutate(fn: (state: State) => R): Promise + mutate(fn: (state: State) => R): R lens(selector: (state: State) => S2): Lens } export type Optic = { getState: () => State - mutate: (fn: (state: State) => R) => Promise + mutate: (fn: (state: State) => R) => R registerLens: (lens: Lens) => void } diff --git a/s/prism/vault/vault.ts b/s/prism/vault/vault.ts index 845994a..c2b21c2 100644 --- a/s/prism/vault/vault.ts +++ b/s/prism/vault/vault.ts @@ -8,7 +8,7 @@ export class Vault { const {store, version, prism} = this.options const pickle = await store.get() if (pickle && pickle.version === version) - await prism.set(pickle.state) + prism.set(pickle.state) } save = async() => { diff --git a/s/signals/batch.ts b/s/signals/batch.ts new file mode 100644 index 0000000..4cfdd8f --- /dev/null +++ b/s/signals/batch.ts @@ -0,0 +1,5 @@ + +import {tracker} from "../tracker/global.js" + +export const batch = tracker.batch + diff --git a/s/signals/derived.ts b/s/signals/derived.ts new file mode 100644 index 0000000..51cbb5d --- /dev/null +++ b/s/signals/derived.ts @@ -0,0 +1,62 @@ + +import {Derived} from "./types.js" +import {watch} from "./utils/watch.js" +import {tracker} from "../tracker/global.js" + +export function derived(fn: () => Value): Derived { + let value!: Value + let dirty = true + let failed = false + let computing = false + let unwatch = () => {} + + const dispose = () => { + unwatch() + unwatch = () => {} + dirty = true + } + + const invalidate = () => { + if (!dirty || failed) { + dirty = true + tracker.write(d) + } + } + + const compute = () => { + if (computing) + throw new Error("derived circularity forbidden") + + computing = true + try { + const watched = watch(fn, () => invalidate()) + + unwatch() + unwatch = watched.dispose + + value = watched.value + dirty = false + failed = false + } + catch (error) { + failed = true + throw error + } + finally { + computing = false + } + } + + function d() { + tracker.read(d) + + if (dirty) + compute() + + return value + } + + d.dispose = dispose + return d +} + diff --git a/s/signals/derived/class.ts b/s/signals/derived/class.ts deleted file mode 100644 index 9c59975..0000000 --- a/s/signals/derived/class.ts +++ /dev/null @@ -1,34 +0,0 @@ - -import {Sub} from "@e280/stz" -import {derived} from "./fn.js" -import {SignalOptions} from "../types.js" -import {tracker} from "../../tracker/tracker.js" - -export interface Derived { - (): Value -} - -export class Derived { - sneak!: Value - on!: Sub<[Value]> - dispose!: () => void - - constructor(formula: () => Value, options?: Partial) { - if (new.target !== Derived) throw new Error("Derived cannot be subclassed") - return derived(formula, options) - } - - get value() { - return this.get() - } - - get() { - tracker.notifyRead(this) - return this.sneak - } - - toString() { - return `(derived "${String(this.get())}")` - } -} - diff --git a/s/signals/derived/fn.ts b/s/signals/derived/fn.ts deleted file mode 100644 index b829be3..0000000 --- a/s/signals/derived/fn.ts +++ /dev/null @@ -1,35 +0,0 @@ - -import {sub} from "@e280/stz" -import {Derived} from "./class.js" -import {watch} from "../effect/watch.js" -import {SignalOptions} from "../types.js" -import {tracker} from "../../tracker/tracker.js" -import {defaultCompare} from "../utils/default-compare.js" - -export function derived(formula: () => Value, options?: Partial) { - function fn(): Value { - return (fn as Derived).get() - } - - const compare = options?.compare ?? defaultCompare - - Object.setPrototypeOf(fn, Derived.prototype) - fn.on = sub<[Value]>() - - const {result, dispose} = watch(formula, async(value) => { - const isChanged = !compare(fn.sneak, value) - if (isChanged) { - fn.sneak = value - await Promise.all([ - tracker.notifyWrite(fn), - fn.on.pub(value), - ]) - } - }) - - fn.sneak = result - fn.dispose = dispose - - return fn as Derived -} - diff --git a/s/signals/derived/test.ts b/s/signals/derived/test.ts deleted file mode 100644 index d746427..0000000 --- a/s/signals/derived/test.ts +++ /dev/null @@ -1,122 +0,0 @@ - -import {Science, test, expect, spy} from "@e280/science" -import {derived} from "./fn.js" -import {signal} from "../signal/fn.js" -import {effect} from "../effect/effect.js" - -export default Science.suite({ - "basic": test(async() => { - const a = signal(1) - const b = signal(10) - const product = derived(() => a.get() * b.get()) - expect(product.get()).is(10) - - await a.set(2) - expect(product.get()).is(20) - }), - - "effect reacts to derived changes": test(async() => { - const a = signal(1) - const b = signal(10) - const product = derived(() => a.value * b.value) - - let mutations = 0 - effect(() => { - void product.get() - mutations++ - }) - expect(product.value).is(10) - expect(mutations).is(1) - - await a.set(2) - expect(product.value).is(20) - expect(mutations).is(2) - - await a.set(3) - expect(product.value).is(30) - expect(mutations).is(3) - }), - - "effect doesn't overreact to derived": test(async() => { - const a = signal(1) - const b = signal(10) - const product = derived(() => a.value * b.value) - - const derivedSpy = spy(() => {}) - product.on(derivedSpy) - - let mutations = 0 - effect(() => { - a.get() - product.get() - mutations++ - }) - expect(product.value).is(10) - expect(mutations).is(1) - expect(derivedSpy.spy.calls.length).is(0) - - await a.set(2) - expect(product.value).is(20) - expect(mutations).is(2) - expect(derivedSpy.spy.calls.length).is(1) - }), - - "derived handles changing deps": test(async() => { - const toggle = signal(true) - const a = signal(1) - const b = signal(2) - const comp = derived(() => toggle() ? a() : b()) - expect(comp()).is(1) - await toggle(false) - expect(comp()).is(2) - await b(3) - expect(comp()).is(3) - }), - - "derived.on": test(async() => { - const a = signal(1) - const b = signal(10) - const product = derived(() => a.value * b.value) - expect(product.value).is(10) - - const mole = spy((_v: number) => {}) - product.on(mole) - expect(mole.spy.calls.length).is(0) - - await a.set(2) - expect(product.value).is(20) - expect(mole.spy.calls.length).is(1) - expect(mole.spy.calls[0].args[0]).is(20) - }), - - "derived.on not called if result doesn't change": test(async() => { - const a = signal(1) - const b = signal(10) - const product = derived(() => a.value * b.value) - expect(product.value).is(10) - - const mole = spy((_v: number) => {}) - product.on(mole) - expect(mole.spy.calls.length).is(0) - - await a.set(2) - expect(product.value).is(20) - expect(mole.spy.calls.length).is(1) - expect(mole.spy.calls[0].args[0]).is(20) - - await a.set(2) - expect(product.value).is(20) - expect(mole.spy.calls.length).is(1) - }), - - "hipster syntax": test(async() => { - const a = signal(1) - const b = signal(10) - const product = derived(() => a() * b()) - expect(product()).is(10) - - await a(2) - expect(product()).is(20) - }), -}) - diff --git a/s/signals/effect.ts b/s/signals/effect.ts new file mode 100644 index 0000000..dcdcf49 --- /dev/null +++ b/s/signals/effect.ts @@ -0,0 +1,22 @@ + +import {watch} from "./utils/watch.js" + +export function effect(fn: () => void) { + let unwatch = () => {} + + const dispose = () => { + unwatch() + unwatch = () => {} + } + + const update = () => { + dispose() + const watched = watch(fn, update) + unwatch = watched.dispose + } + + update() + + return dispose +} + diff --git a/s/signals/effect/effect.ts b/s/signals/effect/effect.ts deleted file mode 100644 index b3f25d0..0000000 --- a/s/signals/effect/effect.ts +++ /dev/null @@ -1,11 +0,0 @@ - -import {watch} from "./watch.js" - -export function effect( - collector: () => Value, - responder?: (value: Value) => void, - ) { - - return watch(collector, responder).dispose -} - diff --git a/s/signals/effect/test.ts b/s/signals/effect/test.ts deleted file mode 100644 index e3cee34..0000000 --- a/s/signals/effect/test.ts +++ /dev/null @@ -1,172 +0,0 @@ - -import {Science, test, expect} from "@e280/science" -import {watch} from "./watch.js" -import {effect} from "./effect.js" -import {signal} from "../signal/fn.js" - -export default Science.suite({ - "watch": Science.suite({ - "responder gets value": test(async() => { - const count = signal(1) - let collected = 0 - watch( - () => count(), - x => { collected = x } - ) - expect(collected).is(0) - await count(2) - expect(collected).is(2) - }), - - "responder not called until change": test(async() => { - const count = signal(1) - let calls = 0 - watch( - () => count(), - () => { calls++ } - ) - expect(calls).is(0) - await count(2) - expect(calls).is(1) - }), - - "watch updates dynamic dependencies": test(async() => { - const toggle = signal(true) - const a = signal(1) - const b = signal(10) - - let collected = 0 - - watch( - () => toggle() ? a() : b(), - x => { collected = x } - ) - - await a(2) - expect(collected).is(2) - - await toggle(false) - expect(collected).is(10) - - collected = 0 - await a(3) - expect(collected).is(0) - - await b(11) - expect(collected).is(11) - }) - }), - - "tracks signal changes": test(async() => { - const count = signal(1) - let doubled = 0 - - effect(() => doubled = count.value * 2) - expect(doubled).is(2) - - await count.set(3) - expect(doubled).is(6) - }), - - "correct signal effect order": test(async() => { - let order: string[] = [] - const count = signal(0) - - effect(() => { - if (count.value) - order.push("effect") - }) - - order.push("before") - await count.set(1) - order.push("after") - - expect(order.length).is(3) - expect(order[0]).is("before") - expect(order[1]).is("effect") - expect(order[2]).is("after") - }), - - "simple effect called the correct number of times": test(async() => { - const count = signal(0) - let runs = 0 - effect(() => { count(); runs++ }) - expect(runs).is(1) - await count(1) - expect(runs).is(2) - await count(2) - expect(runs).is(3) - }), - - "is only called when signal actually changes": test(async() => { - const count = signal(1) - let runs = 0 - effect(() => { - count.get() - runs++ - }) - expect(runs).is(1) - await count.set(999) - expect(runs).is(2) - await count.set(999) - expect(runs).is(2) - }), - - "debounced": test(async() => { - const count = signal(1) - let runs = 0 - effect(() => { - count.get() - runs++ - }) - expect(runs).is(1) - count.value++ - count.value++ - await count.set(count.get() + 1) - expect(runs).is(2) - }), - - "can be disposed": test(async() => { - const count = signal(1) - let doubled = 0 - - const dispose = effect(() => doubled = count.value * 2) - expect(doubled).is(2) - - await count.set(3) - expect(doubled).is(6) - - dispose() - await count.set(4) - expect(doubled).is(6) // old value - }), - - "signal set promise waits for effects": test(async() => { - const count = signal(1) - let doubled = 0 - - effect(() => doubled = count.value * 2) - expect(doubled).is(2) - - await count.set(3) - expect(doubled).is(6) - }), - - "only runs on change": test(async() => { - const sig = signal("a") - let runs = 0 - - effect(() => { - sig.value - runs++ - }) - expect(runs).is(1) - - await sig.set("a") - expect(runs).is(1) - - await sig.set("b") - expect(runs).is(2) - }), -}) - diff --git a/s/signals/effect/watch.ts b/s/signals/effect/watch.ts deleted file mode 100644 index 0784bed..0000000 --- a/s/signals/effect/watch.ts +++ /dev/null @@ -1,32 +0,0 @@ - -import {microbounce} from "@e280/stz" -import {tracker} from "../../tracker/tracker.js" - -export function watch( - collector: () => Value, - responder?: (value: Value) => void, - ) { - - let disposers: (() => void)[] = [] - - const dispose = () => { - for (const d of disposers) d() - disposers = [] - } - - const run = () => { - const {seen, result} = tracker.observe(collector) - for (const saw of seen) - disposers.push(tracker.subscribe(saw, reset)) - return result - } - - const reset = microbounce(() => { - dispose() - if (responder) responder(run()) - else run() - }) - - return {result: run(), dispose} -} - diff --git a/s/signals/helpers/after-effect.ts b/s/signals/helpers/after-effect.ts new file mode 100644 index 0000000..de9e097 --- /dev/null +++ b/s/signals/helpers/after-effect.ts @@ -0,0 +1,20 @@ + +import {effect} from "../effect.js" + +export function afterEffect( + collector: () => C, + responder: (collected: C) => void, + ) { + + let initialized = false + + return effect(() => { + const collected = collector() + + if (initialized) + responder(collected) + + initialized = true + }) +} + diff --git a/s/signals/index.ts b/s/signals/index.ts index 358e170..2dce6f8 100644 --- a/s/signals/index.ts +++ b/s/signals/index.ts @@ -1,18 +1,10 @@ -export * from "./derived/fn.js" -export * from "./derived/class.js" - -export * from "./effect/effect.js" -export * from "./effect/watch.js" - -export * from "./lazy/fn.js" -export * from "./lazy/class.js" - +export * from "./helpers/after-effect.js" export * from "./r/map.js" export * from "./r/set.js" - -export * from "./signal/fn.js" -export * from "./signal/class.js" - +export * from "./batch.js" +export * from "./derived.js" +export * from "./effect.js" +export * from "./signal.js" export * from "./types.js" diff --git a/s/signals/lazy/class.ts b/s/signals/lazy/class.ts deleted file mode 100644 index 48cde59..0000000 --- a/s/signals/lazy/class.ts +++ /dev/null @@ -1,74 +0,0 @@ - -import {lazy} from "./fn.js" -import {SignalOptions} from "../types.js" -import {tracker} from "../../tracker/tracker.js" -import {_collect, _compare, _dirty, _disposers, _effect, _formula} from "../utils/symbols.js" - -export interface Lazy { - (): Value -} - -export class Lazy { - sneak!: Value - ;[_formula]!: () => Value - ;[_dirty]!: boolean - ;[_disposers]!: (() => void)[] - ;[_effect]!: (() => void) | undefined - ;[_compare]!: (a: any, b: any) => boolean - - constructor(formula: () => Value, options?: Partial) { - if (new.target !== Lazy) throw new Error("Lazy cannot be subclassed") - return lazy(formula, options) - } - - get value() { - return this.get() - } - - [_collect]() { - for (const d of this[_disposers]) d() - this[_disposers] = [] - - const {seen, result} = tracker.observe(this[_formula]) - - const markDirty = async() => { this[_dirty] = true } - for (const saw of seen) - this[_disposers].push(tracker.subscribe(saw, markDirty)) - - this[_effect] = () => { - for (const d of this[_disposers]) d() - this[_disposers] = [] - } - - return result - } - - get() { - if (!this[_effect]) { - this.sneak = this[_collect]() - this[_dirty] = false - } - else if (this[_dirty]) { - this[_dirty] = false - const value = this[_collect]() - const isChanged = !this[_compare](this.sneak, value) - if (isChanged) { - this.sneak = value - tracker.notifyWrite(this) - } - } - - tracker.notifyRead(this) - return this.sneak - } - - dispose() { - if (this[_effect]) - this[_effect]() - } - - toString() { - return `($lazy "${String(this.get())}")` - } -} - diff --git a/s/signals/lazy/fn.ts b/s/signals/lazy/fn.ts deleted file mode 100644 index 00b6cce..0000000 --- a/s/signals/lazy/fn.ts +++ /dev/null @@ -1,22 +0,0 @@ - -import {Lazy} from "./class.js" -import {SignalOptions} from "../types.js" -import {defaultCompare} from "../utils/default-compare.js" -import {_compare, _dirty, _disposers, _effect, _formula} from "../utils/symbols.js" - -export function lazy(formula: () => Value, options?: Partial) { - function fn(): Value { - return (fn as Lazy).get() - } - - Object.setPrototypeOf(fn, Lazy.prototype) - fn.sneak = undefined - fn[_formula] = formula - fn[_dirty] = false - fn[_effect] = undefined - fn[_disposers] = [] as any - fn[_compare] = options?.compare ?? defaultCompare - - return fn as Lazy -} - diff --git a/s/signals/lazy/test.ts b/s/signals/lazy/test.ts deleted file mode 100644 index 6ee4b1b..0000000 --- a/s/signals/lazy/test.ts +++ /dev/null @@ -1,75 +0,0 @@ - -import {Science, test, expect} from "@e280/science" -import {lazy} from "./fn.js" -import {signal} from "../signal/fn.js" - -export default Science.suite({ - "lazy values": test(async() => { - const a = signal(2) - const b = signal(3) - const sum = lazy(() => a.value + b.value) - expect(sum.value).is(5) - - await a.set(5) - expect(sum.value).is(8) - - await b.set(7) - expect(sum.value).is(12) - }), - - "lazy is lazy": test(async() => { - const a = signal(1) - let runs = 0 - - const comp = lazy(() => { - runs++ - return a.value * 10 - }) - - expect(runs).is(0) - expect(comp.value).is(10) - expect(runs).is(1) - - await a.set(2) - expect(runs).is(1) - expect(comp.value).is(20) - expect(runs).is(2) - }), - - "lazy handles changing deps": test(async() => { - const toggle = signal(true) - const a = signal(1) - const b = signal(2) - const comp = lazy(() => toggle() ? a() : b()) - expect(comp()).is(1) - await toggle(false) - expect(comp()).is(2) - await b(3) - expect(comp()).is(3) - }), - - "lazy syntax": test(async() => { - const a = signal(2) - const b = signal(3) - const sum = lazy(() => a.value + b.value) - expect(sum.value).is(5) - - await a.set(5) - expect(sum.value).is(8) - expect(sum.get()).is(8) - }), - - "lazy hipster syntax": test(async() => { - const a = signal(2) - const b = signal(3) - const sum = lazy(() => a() + b()) - expect(sum()).is(5) - - await a(5) - expect(sum()).is(8) - - await b(7) - expect(sum()).is(12) - }), -}) - diff --git a/s/signals/r/map.ts b/s/signals/r/map.ts index 6e11e01..9cb42ef 100644 --- a/s/signals/r/map.ts +++ b/s/signals/r/map.ts @@ -1,63 +1,74 @@ -import {GMap} from "@e280/stz" -import {tracker} from "../../tracker/tracker.js" +import {tracker} from "../../tracker/global.js" + +export class RMap extends Map { + + // + // reading + // -export class RMap extends GMap { get size() { - tracker.notifyRead(this) + tracker.read(this) return super.size } ;[Symbol.iterator]() { - tracker.notifyRead(this) + tracker.read(this) return super[Symbol.iterator]() } + forEach(callbackFn: (value: V, key: K, map: RMap) => void) { + tracker.read(this) + for (const [key, value] of this) + callbackFn(value, key, this) + return this + } + keys() { - tracker.notifyRead(this) + tracker.read(this) return super.keys() } values() { - tracker.notifyRead(this) + tracker.read(this) return super.values() } entries() { - tracker.notifyRead(this) + tracker.read(this) return super.entries() } - forEach(callbackFn: (value: V, key: K, map: Map) => void) { - tracker.notifyRead(this) - return super.forEach(callbackFn) - } - has(key: K) { - tracker.notifyRead(this) + tracker.read(this) return super.has(key) } get(key: K) { - tracker.notifyRead(this) + tracker.read(this) return super.get(key) } + // + // writing + // + set(key: K, value: V) { - const r = super.set(key, value) - tracker.notifyWrite(this) - return r + super.set(key, value) + tracker.write(this) + return this } delete(key: K) { - const r = super.delete(key) - tracker.notifyWrite(this) - return r + const ret = super.delete(key) + tracker.write(this) + return ret } clear() { super.clear() - tracker.notifyWrite(this) + tracker.write(this) + return this } } diff --git a/s/signals/r/set.ts b/s/signals/r/set.ts index 64793a2..6015485 100644 --- a/s/signals/r/set.ts +++ b/s/signals/r/set.ts @@ -1,43 +1,58 @@ -import {GSet} from "@e280/stz" -import {tracker} from "../../tracker/tracker.js" +import {tracker} from "../../tracker/global.js" + +export class RSet extends Set { + + // + // reading + // -export class RSet extends GSet { get size() { - tracker.notifyRead(this) + tracker.read(this) return super.size } ;[Symbol.iterator]() { - tracker.notifyRead(this) + tracker.read(this) return super[Symbol.iterator]() } + forEach(callbackFn: (value: V, value2: V, set: RSet) => void) { + tracker.read(this) + for (const value of this) + callbackFn(value, value, this) + return this + } + values() { - tracker.notifyRead(this) + tracker.read(this) return super.values() } - has(item: T) { - tracker.notifyRead(this) + has(item: V) { + tracker.read(this) return super.has(item) } - add(item: T) { + // + // writing + // + + add(item: V) { super.add(item) - tracker.notifyWrite(this) + tracker.write(this) return this } - delete(item: T) { - const r = super.delete(item) - tracker.notifyWrite(this) - return r + delete(item: V) { + const ret = super.delete(item) + tracker.write(this) + return ret } clear() { super.clear() - tracker.notifyWrite(this) + tracker.write(this) return this } } diff --git a/s/signals/signal.ts b/s/signals/signal.ts new file mode 100644 index 0000000..3f99463 --- /dev/null +++ b/s/signals/signal.ts @@ -0,0 +1,18 @@ + +import {Signal} from "./types.js" +import {tracker} from "../tracker/global.js" + +export function signal(value: Value): Signal { + return function sig() { + if (arguments.length === 0) { + tracker.read(sig) + return value + } + else { + value = arguments[0] + tracker.write(sig) + return value + } + } +} + diff --git a/s/signals/signal/class.ts b/s/signals/signal/class.ts deleted file mode 100644 index dc09472..0000000 --- a/s/signals/signal/class.ts +++ /dev/null @@ -1,77 +0,0 @@ - -import {Sub} from "@e280/stz" -import {signal} from "./fn.js" -import {SignalOptions} from "../types.js" -import {tracker} from "../../tracker/tracker.js" -import {_compare, _lock} from "../utils/symbols.js" - -export interface Signal { - (): Value - (value: Value): Promise -} - -export class Signal { - sneak!: Value - on!: Sub<[Value]> - - ;[_lock]!: boolean - ;[_compare]!: (a: any, b: any) => boolean - - constructor(value: Value, options?: Partial) { - if (new.target !== Signal) throw new Error("Signal cannot be subclassed") - return signal(value, options) - } - - get value() { - return this.get() - } - - set value(value: Value) { - void this.set(value) - } - - get() { - tracker.notifyRead(this) - return this.sneak - } - - async set(value: Value, forcePublish = false) { - const previous = this.sneak - this.sneak = value - if (forcePublish || !this[_compare](previous, value)) - await this.publish() - return value - } - - async publish(): Promise { - // only wizards are allowed beyond this point. - // - the implementation is subtle - // - it looks wrong, but it's right - // - tarnished alchemists, take heed: lock engages only for sync activity of the async fns (think of the value setter!) - - if (this[_lock]) - throw new Error("forbid circularity") - - const value = this.sneak - let promise: Promise = Promise.resolve() - - try { - this[_lock] = true - promise = Promise.all([ - tracker.notifyWrite(this), - this.on.pub(value), - ]) - } - finally { - this[_lock] = false - } - - await promise - return value - } - - toString() { - return `($signal "${String(this.get())}")` - } -} - diff --git a/s/signals/signal/fn.ts b/s/signals/signal/fn.ts deleted file mode 100644 index 075907d..0000000 --- a/s/signals/signal/fn.ts +++ /dev/null @@ -1,31 +0,0 @@ - -import {sub} from "@e280/stz" -import {Signal} from "./class.js" -import {lazy} from "../lazy/fn.js" -import {SignalOptions} from "../types.js" -import {derived} from "../derived/fn.js" -import {_compare, _lock} from "../utils/symbols.js" -import {defaultCompare} from "../utils/default-compare.js" - -export function signal(value: Value, options?: Partial) { - function fn(): Value - function fn(value: Value): Promise - function fn(_value?: Value): Value | Promise { - const self = fn as Signal - return (arguments.length === 0) - ? self.get() - : self.set(arguments[0]) - } - - Object.setPrototypeOf(fn, Signal.prototype) - fn.sneak = value - fn.on = sub<[Value]>() - fn[_lock] = false - fn[_compare] = options?.compare ?? defaultCompare - - return fn as Signal -} - -signal.derived = derived -signal.lazy = lazy - diff --git a/s/signals/signal/test.ts b/s/signals/signal/test.ts deleted file mode 100644 index e22c24f..0000000 --- a/s/signals/signal/test.ts +++ /dev/null @@ -1,112 +0,0 @@ - -import {Science, test, expect} from "@e280/science" -import {signal} from "./fn.js" -import {Signal} from "./class.js" - -export default Science.suite({ - "get() and set()": test(async() => { - const count = signal(0) - expect(count.get()).is(0) - await count.set(1) - expect(count.get()).is(1) - count.set(2) - expect(count.get()).is(2) - }), - - ".value": test(async() => { - const count = signal(0) - expect(count.value).is(0) - count.value++ - expect(count.value).is(1) - count.value = 5 - expect(count.value).is(5) - }), - - "count() and count(1)": test(async() => { - const count = signal(0) - expect(count()).is(0) - await count(1) - expect(count()).is(1) - count(2) - expect(count()).is(2) - }), - - "set with forcePublish returns value": test(async() => { - const count = signal(0) - expect(count.value).is(0) - expect(await count.set(1)).is(1) - expect(await count.set(2, true)).is(2) - }), - - "syntax interop": test(async() => { - const count = signal(0) - - count(1) - expect(count()).is(1) - expect(count.get()).is(1) - expect(count.value).is(1) - - count.set(2) - expect(count()).is(2) - expect(count.get()).is(2) - expect(count.value).is(2) - - count.value = 3 - expect(count()).is(3) - expect(count.get()).is(3) - expect(count.value).is(3) - }), - - "class magic": { - "signal instanceof Signal": test(async() => { - const count = signal(0) - expect(count instanceof Signal).ok() - }), - - "new Signal": test(async() => { - const count = new Signal(0) - expect(count instanceof Signal).ok() - expect(count()).is(0) - await count(1) - expect(count.value).is(1) - }), - - "subclassing Signal is forbidden": test(async() => { - expect(() => new class extends Signal {}(0)).throws() - }), - }, - - "on": { - "on is not debounced": test(async() => { - const count = signal(1) - let runs = 0 - count.on(() => void runs++) - await count.set(2) - await count.set(3) - expect(runs).is(2) - }), - - "on only fires on change": test(async() => { - const count = signal(1) - let runs = 0 - count.on(() => void runs++) - await count.set(2) - await count.set(2) - expect(runs).is(1) - }), - - "on circularity forbidden": test(async() => { - const count = signal(1) - let runs = 0 - count.on(async() => { - await count.set(99) - runs++ - }) - expect(async() => { - await count.set(2) - }).throwsAsync() - expect(runs).is(0) - }), - }, -}) - diff --git a/s/signals/test.ts b/s/signals/test.ts index 281372e..7308eb9 100644 --- a/s/signals/test.ts +++ b/s/signals/test.ts @@ -1,15 +1,273 @@ -import {Science} from "@e280/science" - -import signal from "./signal/test.js" -import derived from "./derived/test.js" -import lazy from "./lazy/test.js" -import effect from "./effect/test.js" - -export default Science.suite({ - signal, - derived, - lazy, - effect, -}) +import {expect, science, test} from "@e280/science" +import {batch} from "./batch.js" +import {effect} from "./effect.js" +import {signal} from "./signal.js" +import {derived} from "./derived.js" +import {tracker} from "../tracker/global.js" + +export default science.suite({ + "effect": test(async() => { + const item = {} + let calls = 0 + const stop = effect(() => { + tracker.read(item) + calls++ + }) + expect(calls).is(1) + + tracker.write(item) + expect(calls).is(2) + + stop() + tracker.write(item) + expect(calls).is(2) + }), + + "signal read/write": test(async() => { + const $count = signal(1) + expect($count()).is(1) + $count(2) + expect($count()).is(2) + }), + + "signal triggers effects": test(async() => { + const $count = signal(1) + let calls = 0 + effect(() => { + $count() + calls++ + }) + expect(calls).is(1) + $count(2) + expect(calls).is(2) + }), + + "shorthand effect syntax": test(async() => { + const $alpha = signal(2) + const $bravo = signal(10) + const calls: number[] = [] + effect(() => calls.push($alpha() * $bravo())) + expect(calls.length).is(1) + $alpha(3) + expect(calls.length).is(2) + expect(calls.at(-1)!).is(30) + }), + + "derived": test(async() => { + const $alpha = signal(2) + const $bravo = signal(10) + const $derived = derived(() => $alpha() * $bravo()) + expect($derived()).is(20) + $alpha(3) + expect($derived()).is(30) + }), + + "derived more": test(async() => { + const $alpha = signal(2) + const $bravo = signal(10) + const $derived = derived(() => $alpha() * $bravo()) + expect($derived()).is(20) + $alpha(3) + expect($derived()).is(30) + $alpha(4) + expect($derived()).is(40) + }), + + "derived is lazy": test(async() => { + const $alpha = signal(2) + const $bravo = signal(10) + let calls = 0 + const $derived = derived(() => { + calls++ + return $alpha() * $bravo() + }) + expect(calls).is(0) + expect($derived()).is(20) + expect(calls).is(1) + expect($derived()).is(20) + expect(calls).is(1) + $alpha(3) + $alpha(4) + expect(calls).is(1) + expect($derived()).is(40) + expect(calls).is(2) + }), + + "derived triggers effects": test(async() => { + const $alpha = signal(2) + const $bravo = signal(10) + let derivedCalls = 0 + let effectCalls = 0 + const $derived = derived(() => { + derivedCalls++ + return $alpha() * $bravo() + }) + effect(() => { + $derived() + effectCalls++ + }) + expect(derivedCalls).is(1) + expect(effectCalls).is(1) + $alpha(3) + expect(derivedCalls).is(2) + expect(effectCalls).is(2) + }), + + "derived tracks dynamic dependencies": test(async() => { + const $useAlpha = signal(true) + const $alpha = signal(2) + const $bravo = signal(10) + let calls = 0 + + const $derived = derived(() => { + calls++ + return $useAlpha() + ? $alpha() + : $bravo() + }) + + expect($derived()).is(2) + expect(calls).is(1) + + $bravo(11) + expect($derived()).is(2) + expect(calls).is(1) + + $useAlpha(false) + expect($derived()).is(11) + expect(calls).is(2) + + $alpha(3) + expect($derived()).is(11) + expect(calls).is(2) + + $bravo(12) + expect($derived()).is(12) + expect(calls).is(3) + }), + + "derived remains subscribed after a thrown recompute": test(async() => { + const $throws = signal(false) + const $count = signal(1) + const values: (number | "err")[] = [] + const $derived = derived(() => { + if ($throws()) + throw new Error("boom") + return $count() * 2 + }) + + effect(() => { + try { + values.push($derived()) + } + catch { + values.push("err") + } + }) + + expect(values.join(",")).is("2") + + $throws(true) + expect(values.join(",")).is("2,err") + + $throws(false) + expect(values.join(",")).is("2,err,2") + + $count(3) + expect(values.join(",")).is("2,err,2,6") + }), + + "derived circular reads throw": test(async() => { + let $derived!: () => number + $derived = derived(() => $derived() + 1) + + expect(() => $derived()).throws() + }), + + "batching signal effects seems to work": test(async() => { + const $alpha = signal(2) + const $bravo = signal(10) + let calls: number[] = [] + effect(() => calls.push($alpha() * $bravo())) + calls = [] + batch(() => { + $alpha(3) + $alpha(4) + $alpha(5) + $bravo(1) + $bravo(11) + }) + expect(calls.length).is(1) + expect(calls[0]).is(55) + }), + + "nested batching is fine": test(async() => { + const $alpha = signal(2) + const $bravo = signal(10) + let calls: number[] = [] + effect(() => calls.push($alpha() * $bravo())) + calls = [] + batch(() => { + $alpha(3) + $alpha(4) + batch(() => { + $alpha(5) + $bravo(1) + }) + $bravo(11) + }) + expect(calls.length).is(1) + expect(calls[0]).is(55) + }), + + "evil circularity is no problem": test(async() => { + const $alpha = signal(1) + let count = 0 + effect(() => { + count++ + if (count < 10) + $alpha($alpha() + 1) + }) + expect(count).lt(5) + }), + + "sneaky evil circularity is no problem": test(async() => { + const $alpha = signal(1) + const $bravo = signal(1) + + let countAlpha = 0 + effect(() => { + countAlpha++ + if (countAlpha < 10) + $alpha($bravo() + 1) + }) + + let countBravo = 0 + effect(() => { + countBravo++ + if (countBravo < 10) + $bravo($alpha() + 1) + }) + + $alpha(99) + $bravo(99) + expect(countAlpha).lt(5) + expect(countBravo).lt(5) + }), + + "effect writes are self-damped": test(async() => { + const $count = signal(1) + let calls = 0 + + effect(() => { + calls++ + if (calls < 10) + $count($count() + 1) + }) + + expect(calls).is(1) + expect($count()).is(2) + }), +}) diff --git a/s/signals/types.ts b/s/signals/types.ts index 8ba04ac..9354196 100644 --- a/s/signals/types.ts +++ b/s/signals/types.ts @@ -1,11 +1,13 @@ -import {Lazy} from "./lazy/class.js" -import {Signal} from "./signal/class.js" -import {Derived} from "./derived/class.js" - -export type Signaly = Signal | Derived | Lazy +export type Signal = { + (): Value + (value: Value): Value +} -export type SignalOptions = { - compare: (a: any, b: any) => boolean +export type Derived = { + (): Value + dispose: () => void } +export type Valuable = Signal | Derived + diff --git a/s/signals/utils/default-compare.ts b/s/signals/utils/default-compare.ts deleted file mode 100644 index 4a61764..0000000 --- a/s/signals/utils/default-compare.ts +++ /dev/null @@ -1,5 +0,0 @@ - -export function defaultCompare(a: any, b: any) { - return Object.is(a, b) -} - diff --git a/s/signals/utils/symbols.ts b/s/signals/utils/symbols.ts deleted file mode 100644 index 79e80e8..0000000 --- a/s/signals/utils/symbols.ts +++ /dev/null @@ -1,9 +0,0 @@ - -export const _lock = Symbol() -export const _dirty = Symbol() -export const _effect = Symbol() -export const _collect = Symbol() -export const _compare = Symbol() -export const _formula = Symbol() -export const _disposers = Symbol() - diff --git a/s/signals/utils/watch.ts b/s/signals/utils/watch.ts new file mode 100644 index 0000000..a5d7c12 --- /dev/null +++ b/s/signals/utils/watch.ts @@ -0,0 +1,10 @@ + +import {tracker} from "../../tracker/global.js" + +export function watch(collector: () => Value, onChange: () => void) { + const {seen, value} = tracker.observe(collector) + const disposers = [...seen].map(item => tracker.subscribe(item, onChange)) + const dispose = () => disposers.forEach(d => d()) + return {value, dispose} +} + diff --git a/s/test.ts b/s/test.ts index b3e1e94..a19ffe4 100644 --- a/s/test.ts +++ b/s/test.ts @@ -1,12 +1,11 @@ -import {Science} from "@e280/science" - +import {science} from "@e280/science" import prism from "./prism/test.js" import signals from "./signals/test.js" import tracker from "./tracker/test.js" import wait from "./wait/test.js" -await Science.run({ +await science.run({ prism, signals, tracker, diff --git a/s/tracker/bindings/react.ts b/s/tracker/bindings/react.ts deleted file mode 100644 index 0eb3f74..0000000 --- a/s/tracker/bindings/react.ts +++ /dev/null @@ -1,53 +0,0 @@ - -import {tracker} from "../tracker.js" -import {signal} from "../../signals/signal/fn.js" -import {derived} from "../../signals/derived/fn.js" -import {SignalOptions} from "../../signals/types.js" - -export function react(react: { - useEffect: (fn: () => void | (() => void), deps?: unknown[]) => void - useState: (x: X | (() => X)) => [ - value: X, - set: (value: X | ((x: X) => X)) => void - ] - }) { - - const useStrata = (fn: () => X) => { - const [, setTick] = react.useState(0) - const {seen, result} = tracker.observe(fn) - - react.useEffect(() => { - const rerender = async() => setTick(tick => tick + 1) - const stoppers = [...seen].map(item => tracker.subscribe(item, rerender)) - return () => stoppers.forEach(stop => stop()) - }) - - return result - } - - const component =

(render: (props: P) => R) => { - const c = (props: P) => useStrata(() => render(props)) - c.displayName = (render as any).displayName ?? render.name ?? "Component" - return c - } - - const useOnce = (fn: () => X) => { - const [value] = react.useState(fn) - return value - } - - const useSignal = (value: X, options?: Partial) => { - const $signal = useOnce(() => signal(value, options)) - void useStrata(() => $signal()) - return $signal - } - - const useDerived = (formula: () => X, options?: Partial) => { - const $derived = useOnce(() => derived(formula, options)) - void useStrata(() => $derived()) - return $derived - } - - return {component, useStrata, useOnce, useSignal, useDerived} -} - diff --git a/s/tracker/global.ts b/s/tracker/global.ts new file mode 100644 index 0000000..04a4f0d --- /dev/null +++ b/s/tracker/global.ts @@ -0,0 +1,6 @@ + +import {Tracker} from "./tracker.js" + +/** standard global tracker for integrations */ +export const tracker: Tracker = (globalThis as any)[Symbol.for("e280.tracker.2")] ??= new Tracker() + diff --git a/s/tracker/index.ts b/s/tracker/index.ts index c985571..ffea678 100644 --- a/s/tracker/index.ts +++ b/s/tracker/index.ts @@ -1,4 +1,4 @@ +export * from "./global.js" export * from "./tracker.js" -export * from "./bindings/react.js" diff --git a/s/tracker/test.ts b/s/tracker/test.ts index 855333c..69c5355 100644 --- a/s/tracker/test.ts +++ b/s/tracker/test.ts @@ -1,40 +1,157 @@ -import {Science, test, expect} from "@e280/science" import {Tracker} from "./tracker.js" +import {science, test, expect} from "@e280/science" -export default Science.suite({ - "change waits for downstream effects to settle": test(async() => { +export default science.suite({ + "subscriptions": test(async() => { const tracker = new Tracker() - let order: string[] = [] + let alpha = 0 + let bravo = 0 + const item = {} + const alphaOff = tracker.subscribe(item, () => alpha++) + tracker.subscribe(item, () => bravo++) + + tracker.write(item) + expect(alpha).is(1) + expect(bravo).is(1) + tracker.write(item) + expect(alpha).is(2) + expect(bravo).is(2) + + alphaOff() + tracker.write(item) + expect(alpha).is(2) + expect(bravo).is(3) + }), + + "observe": test(async() => { + const tracker = new Tracker() const item = {} - tracker.subscribe(item, async () => { - await Promise.resolve() - order.push("effect") + + const {seen, value} = tracker.observe(() => { + tracker.read(item) + return 123 }) - order.push("before") - await tracker.notifyWrite(item) - order.push("after") + expect(seen.has(item)).is(true) + expect(value).is(123) + }), + + "nested observe layers are isolated": test(async() => { + const tracker = new Tracker() + const outer = {} + const inner = {} + + const observed = tracker.observe(() => { + tracker.read(outer) + + const nested = tracker.observe(() => { + tracker.read(inner) + }) + + expect(nested.seen.has(inner)).is(true) + expect(nested.seen.has(outer)).is(false) + }) - expect(order.length).is(3) - expect(order[0]).is("before") - expect(order[1]).is("effect") - expect(order[2]).is("after") + expect(observed.seen.has(outer)).is(true) + expect(observed.seen.has(inner)).is(false) }), - "circularity forbidden": test(async() => { + "batch dedupes subscriber calls": test(async() => { const tracker = new Tracker() + let called = 0 const item = {} - // effect re-publishes the same change, creating a cycle - tracker.subscribe(item, async() => { - await tracker.notifyWrite(item) + tracker.subscribe(item, () => called++) + + tracker.batch(() => { + tracker.write(item) + tracker.write(item) + tracker.write(item) }) - expect(async() => { - await tracker.notifyWrite(item) - }).throwsAsync() + expect(called).is(1) + }), + + "nested batch flushes only once": test(async() => { + const tracker = new Tracker() + let called = 0 + const item = {} + + tracker.subscribe(item, () => called++) + + tracker.batch(() => { + tracker.write(item) + + tracker.batch(() => { + tracker.write(item) + expect(called).is(0) + }) + + expect(called).is(0) + }) + + expect(called).is(1) + }), + + "batch flushes cascading writes in waves": test(async() => { + const tracker = new Tracker() + const a = {} + const b = {} + const calls: string[] = [] + + tracker.subscribe(a, () => { + calls.push("a") + tracker.write(b) + }) + + tracker.subscribe(b, () => { + calls.push("b") + }) + + tracker.batch(() => { + tracker.write(a) + }) + + expect(calls.join(",")).is("a,b") + }), + + "writing to a different item in a subscriber is ok": test(async() => { + const tracker = new Tracker() + const alpha = {} + const bravo = {} + + const fn = () => tracker.write(bravo) + tracker.subscribe(alpha, fn) + + expect(() => tracker.write(alpha)).not.throws() + }), + + "circularity is forbidden": test(async() => { + const tracker = new Tracker() + const item = {} + + const fn = () => tracker.write(item) + tracker.subscribe(item, fn) + + expect(() => tracker.write(item)).throws() + }), + + "same subscriber rescheduled during flush is forbidden": test(async() => { + const tracker = new Tracker() + const item = {} + let calls = 0 + + const fn = () => { + calls++ + tracker.write(item) + } + + tracker.subscribe(item, fn) + + expect(() => tracker.write(item)).throws() + expect(calls).is(1) }), }) diff --git a/s/tracker/tracker.ts b/s/tracker/tracker.ts index 8909907..7d8d7e1 100644 --- a/s/tracker/tracker.ts +++ b/s/tracker/tracker.ts @@ -1,62 +1,89 @@ -import {sub, Sub} from "@e280/stz" +import {guarantee} from "@e280/stz" -export type TrackableItem = object | symbol +export type Trackable = object | symbol /** * reactivity integration hub */ -export class Tracker { - #seeables: Set[] = [] - #changeables = new WeakMap() - #changeStack: Set>[] = [] - #busy = new Set() - - /** indicate item was accessed */ - notifyRead(item: Item) { - this.#seeables.at(-1)?.add(item) +export class Tracker { + #busy = new Set<() => void>() + #observationLayers: Set[] = [] + #subscriptions = new WeakMap void>>() + + #batchDepth = 0 + #batchPending = new Set<() => void>() + + /** indicate to observers that this item was accessed */ + read(item: Item) { + const top = this.#observationLayers.at(-1) + top?.add(item) } - /** indicate item was changed */ - async notifyWrite(item: Item) { - if (this.#busy.has(item)) - throw new Error("circularity forbidden") - const prom = this.#guaranteeChangeable(item).pub() - this.#changeStack.at(-1)?.add(prom) - return prom + /** invoke all subscriptions for this item */ + write(item: Item) { + const fns = this.#subscriptions.get(item) + if (!fns) return + + for (const fn of fns) + this.#batchPending.add(fn) + + if (this.#batchDepth === 0) + this.#flush() } - /** collect which items were seen during fn */ - observe(fn: () => R) { - this.#seeables.push(new Set()) - const result = fn() - const seen = this.#seeables.pop()! - return {seen, result} + /** collect items that were read during fn */ + observe(fn: () => Value) { + const seen = new Set() + this.#observationLayers.push(seen) + try { + const value = fn() + return {seen, value} + } + finally { + this.#observationLayers.pop() + } } - /** respond to changes by calling fn */ - subscribe(item: Item, fn: () => Promise) { - return this.#guaranteeChangeable(item)(async() => { - const collected = new Set>() - this.#changeStack.push(collected) - this.#busy.add(item) - collected.add(fn()) - this.#busy.delete(item) - await Promise.all(collected) - this.#changeStack.pop() - }) + /** fn will be called when item changes */ + subscribe(item: Item, fn: () => void) { + const fns = guarantee(this.#subscriptions, item, () => new Set()) + fns.add(fn) + return () => { + fns.delete(fn) + if (fns.size === 0) + this.#subscriptions.delete(item) + } } - #guaranteeChangeable(item: Item) { - let on = this.#changeables.get(item) - if (!on) { - on = sub() - this.#changeables.set(item, on) + batch = (fn: () => R) => { + this.#batchDepth++ + try { + return fn() + } + finally { + this.#batchDepth-- + if (this.#batchDepth === 0) + this.#flush() } - return on } -} -/** standard global tracker for integrations */ -export const tracker: Tracker = (globalThis as any)[Symbol.for("e280.tracker")] ??= new Tracker() + #run(fn: () => void) { + if (this.#busy.has(fn)) + throw new Error("circularity forbidden") + this.#busy.add(fn) + try { fn() } + finally { this.#busy.delete(fn) } + } + + #flush() { + while (this.#batchPending.size > 0) { + const pending = [...this.#batchPending] + this.#batchPending.clear() + + for (const fn of pending) + this.#run(fn) + } + } +} diff --git a/s/wait/index.ts b/s/wait/index.ts index a1dc2df..538790d 100644 --- a/s/wait/index.ts +++ b/s/wait/index.ts @@ -1,7 +1,7 @@ export * from "./parts/get.js" export * from "./parts/is.js" -export * from "./parts/new.js" +export * from "./parts/make.js" export * from "./parts/select.js" export * from "./parts/wait.js" export * from "./parts/type.js" diff --git a/s/wait/parts/is.ts b/s/wait/parts/is.ts index 64a629c..31710eb 100644 --- a/s/wait/parts/is.ts +++ b/s/wait/parts/is.ts @@ -1,8 +1,8 @@ -import {Wait, WaitDone, WaitErr, WaitOk, WaitPending} from "./type.js" +import {Wait, WaitResult, WaitErr, WaitOk, WaitPending} from "./type.js" export const isWaitPending = (wait: Wait): wait is WaitPending => !wait.done -export const isWaitDone = (wait: Wait): wait is WaitDone => wait.done +export const isWaitDone = (wait: Wait): wait is WaitResult => wait.done export const isWaitOk = (wait: Wait): wait is WaitOk => wait.done && wait.ok export const isWaitErr = (wait: Wait): wait is WaitErr => wait.done && !wait.ok diff --git a/s/wait/parts/new.ts b/s/wait/parts/make.ts similarity index 59% rename from s/wait/parts/new.ts rename to s/wait/parts/make.ts index b4d4231..d351e9f 100644 --- a/s/wait/parts/new.ts +++ b/s/wait/parts/make.ts @@ -2,7 +2,7 @@ import {Result} from "@e280/stz" import {Wait} from "./type.js" -export function newWait(result?: Result): Wait { +export function makeWait(result?: Result): Wait { return result ? {done: true, ...result} : {done: false} diff --git a/s/wait/parts/select.ts b/s/wait/parts/select.ts index ac73ad9..6e9fa5f 100644 --- a/s/wait/parts/select.ts +++ b/s/wait/parts/select.ts @@ -4,18 +4,24 @@ import {isWaitOk, isWaitPending} from "./is.js" import {waitNeedErr, waitNeedOk} from "./get.js" export function waitSelect(wait: Wait, select: { - pending: () => Ret, - ok: (value: Value) => Ret - err: (error: E) => Ret + pending?: () => Ret + ok?: (value: Value) => Ret + err?: (error: E) => Ret }) { + const { + pending = () => {}, + ok = () => {}, + err = () => {}, + } = select + if (isWaitPending(wait)) - return select.pending() + return pending() else if (isWaitOk(wait)) - return select.ok(waitNeedOk(wait)) + return ok(waitNeedOk(wait)) else - return select.err(waitNeedErr(wait)) + return err(waitNeedErr(wait)) } diff --git a/s/wait/parts/type.ts b/s/wait/parts/type.ts index f10d323..5f5cff7 100644 --- a/s/wait/parts/type.ts +++ b/s/wait/parts/type.ts @@ -1,20 +1,18 @@ import {Err, Ok, Result} from "@e280/stz" -import {Derived} from "../../signals/index.js" +import {Derived} from "../../signals/types.js" export type WaitPending = {done: false} -export type WaitDone = {done: true} & Result +export type WaitResult = {done: true} & Result export type WaitOk = {done: true} & Ok export type WaitErr = {done: true} & Err export type Wait = | WaitPending - | WaitDone + | WaitResult -export type WaitSignal = - & { - ready: Promise - result: Promise> - } - & Derived> +export type Waiter = Derived> & { + ready: Promise + result: Promise> +} diff --git a/s/wait/parts/wait.ts b/s/wait/parts/wait.ts index cf92b05..3ac3f92 100644 --- a/s/wait/parts/wait.ts +++ b/s/wait/parts/wait.ts @@ -1,33 +1,34 @@ -import {derived, signal} from "@e280/strata" import {attemptAsync, getOk, Result} from "@e280/stz" -import {newWait} from "./new.js" -import {Wait, WaitDone, WaitSignal} from "./type.js" +import {makeWait} from "./make.js" +import {signal} from "../../signals/signal.js" +import {derived} from "../../signals/derived.js" +import {Wait, WaitResult, Waiter} from "./type.js" export function wait( input: Promise | (() => Promise), ) { - return waitResult(attemptAsync(input)) + return waitFormal(attemptAsync(input)) } -export function waitResult( +export function waitFormal( input: Promise> | (() => Promise>), ) { - return waitResultPromise( + return waitFormalPromise( (typeof input === "function") ? input() : input ) } -function waitResultPromise(promise: Promise>) { - const $wait = signal>(newWait()) - const $derived = derived(() => $wait()) as WaitSignal +function waitFormalPromise(promise: Promise>) { + const $wait = signal>(makeWait()) + const $derived = derived(() => $wait()) as Waiter - $derived.result = promise.then(async result => { - const r: WaitDone = {done: true, ...result} - await $wait(r) + $derived.result = promise.then(result => { + const r: WaitResult = {done: true, ...result} + $wait(r) return r })