(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
})