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..e923b7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,32 @@ +
+ +## 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** + - 🟥 renamed `tracker.notifyRead` to `tracker.read` + - 🟥 renamed `tracker.notifyWrite` to `tracker.write` +- 🟥 **signals/derived** + - 🟥 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 +- 🟥 **wait** + - 🟥 renamed `WaitDone` to `WaitResult` to better match ok/err/result + - 🟥 renamed `newWait` to `makeWait` because i like it more + - 🟥 renamed `WaitSignal` to `WaitDerived` because that's what it is + - 🟥 renamed `waitResult` to `waitFormal` because i said so + + +
## v0.3 diff --git a/README.md b/README.md index 77293fa..1b9994c 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,71 @@ > *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** +### 🚦 derived +- **combine signals like a formula** ```ts - $count() // read - await $count(2) // write + const $alpha = signal(1) + const $bravo = signal(10) + const $product = derived(() => $alpha() * $bravo()) ``` -- **signal get/set syntax** +- **it automatically updates** ```ts - $count.get() // read - await $count.set(2) // write - ``` -- **signal .value accessor syntax** - ```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** ```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,** + you can also pass an optional second fn param, which receives what the first fn returns, and is not called initially. + also, 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 +136,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 +161,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 +244,75 @@ import {signal, effect, derived, lazy} from "@e280/strata" ## 🍋 strata wait -> *represent async operations* +> *tiny async state helpers* -- wait is designed to vibe with [stz#ok](https://github.com/e280/stz#ok) - ```ts - import {ok, err} from "@e280/stz" - ``` +***wait*** is small. *pending, ok, err.* +it extends [stz's ok/err](https://github.com/e280/stz#ok). +it's like, for your ui, showing little loading spinners and branching when stuff is loading. -### ⌛ 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} - - // done, err - newWait(err("uh oh")) - // {done: true, ok: false, error: "uh oh"} - ``` - -### ⌛ 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 in a fancy wait** ```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 - ```ts - import {waitResult} from "@e280/strata" - ``` -- do formal rigid error handling because you're super strict and serious +### ⌛ waitFormal is persnickety belt-and-suspenders mode +- **you can get super explicit about the types** ```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 + +### ⌛ wait, there's more +- maker ```ts - await $wait.result - // {done: true, ok: true, value: 123} or - // {done: true, ok: false, error: "bad roll"} + makeWait() // pending + makeWait(ok(123)) + makeWait(err("uh oh")) ``` -- btw, wait and waitResult will actually accept a promise if you like - ```ts - const $wait = wait(Promise.resolve(123)) - ``` - -### ⌛ 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 +321,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 +343,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 +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. - // 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) { - - // 🪄 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) @@ -466,52 +386,57 @@ note, the *items* that the tracker tracks can be any object, or symbol.. the tra ## 🍋 react bindings +```ts +``` + ### ⚛️ 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/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..b794242 --- /dev/null +++ b/s/bindings/react.ts @@ -0,0 +1,52 @@ + +import {signal} from "../core/signal.js" +import {derived} from "../core/derived.js" +import {tracker} from "../tracker/tracker.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/core/batch.ts b/s/core/batch.ts new file mode 100644 index 0000000..dc399dc --- /dev/null +++ b/s/core/batch.ts @@ -0,0 +1,5 @@ + +import {tracker} from "../tracker/tracker.js" + +export const batch = tracker.batch + diff --git a/s/core/derived.ts b/s/core/derived.ts new file mode 100644 index 0000000..06e21dd --- /dev/null +++ b/s/core/derived.ts @@ -0,0 +1,46 @@ + +import {Derived} from "./types.js" +import {watch} from "./utils/watch.js" +import {tracker} from "../tracker/tracker.js" + +export function derived(fn: () => Value): Derived { + let value!: Value + let dirty = true + let unwatch = () => {} + + const dispose = () => { + unwatch() + unwatch = () => {} + dirty = true + } + + const invalidate = () => { + if (!dirty) { + dirty = true + tracker.write(d) + } + } + + const compute = () => { + const watched = watch(fn, invalidate) + + unwatch() + unwatch = watched.dispose + + value = watched.value + dirty = false + } + + function d() { + tracker.read(d) + + if (dirty) + compute() + + return value + } + + d.dispose = dispose + return d +} + diff --git a/s/core/effect.ts b/s/core/effect.ts new file mode 100644 index 0000000..9afbd00 --- /dev/null +++ b/s/core/effect.ts @@ -0,0 +1,28 @@ + +import {watch} from "./utils/watch.js" + +export function effect( + collector: () => Collected, + responder: (collected: Collected) => void = () => {}, + ) { + + let unwatch = () => {} + + const dispose = () => { + unwatch() + unwatch = () => {} + } + + const update = () => { + dispose() + const watched = watch(collector, update) + unwatch = watched.dispose + responder(watched.value) + } + + const watched = watch(collector, update) + unwatch = watched.dispose + + return dispose +} + diff --git a/s/core/index.ts b/s/core/index.ts new file mode 100644 index 0000000..1a13ccd --- /dev/null +++ b/s/core/index.ts @@ -0,0 +1,9 @@ + +export * from "./r/map.js" +export * from "./r/set.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/r/map.ts b/s/core/r/map.ts similarity index 70% rename from s/signals/r/map.ts rename to s/core/r/map.ts index 6e11e01..d83af90 100644 --- a/s/signals/r/map.ts +++ b/s/core/r/map.ts @@ -4,60 +4,61 @@ import {tracker} from "../../tracker/tracker.js" 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]() } 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) + tracker.read(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) } set(key: K, value: V) { const r = super.set(key, value) - tracker.notifyWrite(this) + tracker.write(this) return r } delete(key: K) { const r = super.delete(key) - tracker.notifyWrite(this) + tracker.write(this) return r } clear() { super.clear() - tracker.notifyWrite(this) + tracker.write(this) + return this } } diff --git a/s/signals/r/set.ts b/s/core/r/set.ts similarity index 70% rename from s/signals/r/set.ts rename to s/core/r/set.ts index 64793a2..27e1b87 100644 --- a/s/signals/r/set.ts +++ b/s/core/r/set.ts @@ -4,40 +4,40 @@ import {tracker} from "../../tracker/tracker.js" 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]() } values() { - tracker.notifyRead(this) + tracker.read(this) return super.values() } has(item: T) { - tracker.notifyRead(this) + tracker.read(this) return super.has(item) } add(item: T) { super.add(item) - tracker.notifyWrite(this) + tracker.write(this) return this } delete(item: T) { const r = super.delete(item) - tracker.notifyWrite(this) + tracker.write(this) return r } clear() { super.clear() - tracker.notifyWrite(this) + tracker.write(this) return this } } diff --git a/s/core/signal.ts b/s/core/signal.ts new file mode 100644 index 0000000..f310d4b --- /dev/null +++ b/s/core/signal.ts @@ -0,0 +1,18 @@ + +import {Signal} from "./types.js" +import {tracker} from "../tracker/tracker.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/core/test.ts b/s/core/test.ts new file mode 100644 index 0000000..ad5042e --- /dev/null +++ b/s/core/test.ts @@ -0,0 +1,138 @@ + +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/tracker.js" + +export default science.suite({ + "effect": test(async() => { + const item = {} + let calls = 0 + const stop = effect(() => tracker.read(item), () => calls++) + expect(calls).is(0) + + tracker.write(item) + expect(calls).is(1) + + stop() + tracker.write(item) + expect(calls).is(1) + }), + + "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(0) + $count(2) + expect(calls).is(1) + }), + + "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 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(0) + $alpha(3) + expect(derivedCalls).is(2) + expect(effectCalls).is(1) + }), + + "batching signal effects seems to work": test(async() => { + const $alpha = signal(2) + const $bravo = signal(10) + let calls: number[] = [] + effect(() => $alpha() * $bravo(), value => calls.push(value)) + batch(() => { + $alpha(3) + $alpha(4) + $alpha(5) + $bravo(1) + $bravo(11) + }) + expect(calls.length).is(1) + expect(calls[0]).is(55) + }), + + "direct circularity forbidden": test(async() => { + const $count = signal(1) + effect(() => $count(), count => $count(count + 1)) + expect(() => $count(2)).throws() + }), + + "indirect effect circular write is forbidden": test(async() => { + const $alpha = signal(1) + const $bravo = signal(1) + let calls = 0 + + effect(() => $alpha(), a => { + calls++ + $bravo(a + 1) + }) + + effect(() => $bravo(), b => { + calls++ + $alpha(b + 1) + }) + + expect(() => $alpha(2)).throws() + expect(calls).is(2) + }), +}) + diff --git a/s/core/types.ts b/s/core/types.ts new file mode 100644 index 0000000..9354196 --- /dev/null +++ b/s/core/types.ts @@ -0,0 +1,13 @@ + +export type Signal = { + (): Value + (value: Value): Value +} + +export type Derived = { + (): Value + dispose: () => void +} + +export type Valuable = Signal | Derived + diff --git a/s/core/utils/watch.ts b/s/core/utils/watch.ts new file mode 100644 index 0000000..d66b387 --- /dev/null +++ b/s/core/utils/watch.ts @@ -0,0 +1,10 @@ + +import {tracker} from "../../tracker/tracker.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/index.ts b/s/index.ts index 305e513..58a81b0 100644 --- a/s/index.ts +++ b/s/index.ts @@ -1,6 +1,7 @@ +export * from "./bindings/index.js" +export * from "./core/index.js" export * from "./prism/index.js" -export * from "./signals/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..55334c8 100644 --- a/s/prism/lens.ts +++ b/s/prism/lens.ts @@ -1,5 +1,5 @@ -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 {_optic} from "./utils/optic-symbol.js" @@ -8,17 +8,10 @@ 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..1f603c0 100644 --- a/s/prism/prism.ts +++ b/s/prism/prism.ts @@ -1,5 +1,4 @@ -import {microbounce, sub} from "@e280/stz" import {Lens} from "./lens.js" import {tracker} from "../tracker/tracker.js" @@ -8,31 +7,27 @@ 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..6be32c3 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 "../core/effect.js" import {Chrono} from "./chrono/chrono.js" import {chronicle} from "./chrono/chronicle.js" -import {effect} from "../signals/effect/effect.js" +import { batch } from "../core/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,46 @@ 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")) + effect(() => lens.state, () => happenings++) + lens.mutate(s => s.array.push("lmao")) expect(happenings).is(1) 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 +110,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 +127,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 +143,11 @@ 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++) + effect(() => lensA.state, () => happeningsA++) + effect(() => lensB.state, () => happeningsB++) + lensA.mutate(s => s.count++) expect(happeningsA).is(1) expect(happeningsB).is(0) - stopA() - stopB() }), "outside mutations ignored for effects": test(async() => { @@ -160,19 +156,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 +182,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 +194,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 +214,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 +238,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 +253,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 +284,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 +292,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 +300,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 +312,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 +327,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/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/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/index.ts b/s/signals/index.ts deleted file mode 100644 index 358e170..0000000 --- a/s/signals/index.ts +++ /dev/null @@ -1,18 +0,0 @@ - -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 "./r/map.js" -export * from "./r/set.js" - -export * from "./signal/fn.js" -export * from "./signal/class.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/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 deleted file mode 100644 index 281372e..0000000 --- a/s/signals/test.ts +++ /dev/null @@ -1,15 +0,0 @@ - -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, -}) - diff --git a/s/signals/types.ts b/s/signals/types.ts deleted file mode 100644 index 8ba04ac..0000000 --- a/s/signals/types.ts +++ /dev/null @@ -1,11 +0,0 @@ - -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 SignalOptions = { - compare: (a: any, b: any) => boolean -} - 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/test.ts b/s/test.ts index b3e1e94..47cb228 100644 --- a/s/test.ts +++ b/s/test.ts @@ -1,15 +1,14 @@ -import {Science} from "@e280/science" - +import {science} from "@e280/science" +import core from "./core/test.js" +import wait from "./wait/test.js" 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({ - prism, - signals, +await science.run({ tracker, + core, wait, + prism, }) 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/index.ts b/s/tracker/index.ts index c985571..3388e88 100644 --- a/s/tracker/index.ts +++ b/s/tracker/index.ts @@ -1,4 +1,3 @@ export * from "./tracker.js" -export * from "./bindings/react.js" diff --git a/s/tracker/test.ts b/s/tracker/test.ts index 855333c..ad59c12 100644 --- a/s/tracker/test.ts +++ b/s/tracker/test.ts @@ -1,40 +1,145 @@ -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 = {} - expect(order.length).is(3) - expect(order[0]).is("before") - expect(order[1]).is("effect") - expect(order[2]).is("after") + 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(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") + }), + + "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..b3debdc 100644 --- a/s/tracker/tracker.ts +++ b/s/tracker/tracker.ts @@ -1,62 +1,92 @@ -import {sub, Sub} from "@e280/stz" +import {GWeakMap} 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 GWeakMap 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 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() + } + } + + /** fn will be called when item changes */ + subscribe(item: Item, fn: () => void) { + const fns = this.#subscriptions.guarantee(item, () => new Set()) + fns.add(fn) + return () => { + fns.delete(fn) + if (fns.size === 0) + this.#subscriptions.delete(item) + } } - /** 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} + batch = (fn: () => R) => { + this.#batchDepth++ + try { + return fn() + } + finally { + this.#batchDepth-- + if (this.#batchDepth === 0) + this.#flush() + } } - /** 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() - }) + #run(fn: () => void) { + if (this.#busy.has(fn)) + throw new Error("circularity forbidden") + this.#busy.add(fn) + try { fn() } + finally { this.#busy.delete(fn) } } - #guaranteeChangeable(item: Item) { - let on = this.#changeables.get(item) - if (!on) { - on = sub() - this.#changeables.set(item, on) + #flush() { + while (this.#batchPending.size > 0) { + const pending = [...this.#batchPending] + this.#batchPending.clear() + + for (const fn of pending) + this.#run(fn) } - return on } } /** standard global tracker for integrations */ -export const tracker: Tracker = (globalThis as any)[Symbol.for("e280.tracker")] ??= new Tracker() +export const tracker: Tracker = (globalThis as any)[Symbol.for("e280.tracker.2")] ??= new Tracker() 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..b1855b7 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 "../../core/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 WaitDerived = Derived> & { + ready: Promise + result: Promise> +} diff --git a/s/wait/parts/wait.ts b/s/wait/parts/wait.ts index cf92b05..89f1b3a 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 "../../core/signal.js" +import {derived} from "../../core/derived.js" +import {Wait, WaitResult, WaitDerived} 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 WaitDerived - $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 })