From 3618d49d68e8f573f1c01ba12fe75c992898750f Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 15:45:21 -0700 Subject: [PATCH 01/48] nova: setting forth a new vision --- s/nova/primitives/signal/signal.ts | 0 s/nova/tracker/index.ts | 3 + s/nova/tracker/test.ts | 129 +++++++++++++++++++++++++++++ s/nova/tracker/tracker.ts | 92 ++++++++++++++++++++ s/test.ts | 15 +--- 5 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 s/nova/primitives/signal/signal.ts create mode 100644 s/nova/tracker/index.ts create mode 100644 s/nova/tracker/test.ts create mode 100644 s/nova/tracker/tracker.ts diff --git a/s/nova/primitives/signal/signal.ts b/s/nova/primitives/signal/signal.ts new file mode 100644 index 0000000..e69de29 diff --git a/s/nova/tracker/index.ts b/s/nova/tracker/index.ts new file mode 100644 index 0000000..3388e88 --- /dev/null +++ b/s/nova/tracker/index.ts @@ -0,0 +1,3 @@ + +export * from "./tracker.js" + diff --git a/s/nova/tracker/test.ts b/s/nova/tracker/test.ts new file mode 100644 index 0000000..b4c46a9 --- /dev/null +++ b/s/nova/tracker/test.ts @@ -0,0 +1,129 @@ + +import {Tracker} from "../tracker.js" +import {science, test, expect} from "@e280/science" + +export default science.suite({ + "subscriptions": test(async() => { + const tracker = new Tracker() + 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 = {} + + const {seen, ret} = tracker.observe(() => { + tracker.read(item) + return 123 + }) + + expect(seen.has(item)).is(true) + expect(ret).is(123) + }), + + "nested observe layers are isolated": test(async() => { + const tracker = new Tracker() + const outer = {} + const inner = {} + + const observed = tracker.observe(() => { + tracker.read(outer) + + const nested = tracker.observe(() => { + tracker.read(inner) + }) + + expect(nested.seen.has(inner)).is(true) + expect(nested.seen.has(outer)).is(false) + }) + + expect(observed.seen.has(outer)).is(true) + expect(observed.seen.has(inner)).is(false) + }), + + "batch dedupes subscriber calls": test(async() => { + const tracker = new Tracker() + let called = 0 + const item = {} + + tracker.subscribe(item, () => called++) + + tracker.batch(() => { + tracker.write(item) + tracker.write(item) + tracker.write(item) + }) + + 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() + }), +}) diff --git a/s/nova/tracker/tracker.ts b/s/nova/tracker/tracker.ts new file mode 100644 index 0000000..beafdbd --- /dev/null +++ b/s/nova/tracker/tracker.ts @@ -0,0 +1,92 @@ + +import {GWeakMap} from "@e280/stz" + +export type TrackableItem = object | symbol + +/** + * reactivity integration hub + */ +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) + } + + /** 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: () => R) { + const seen = new Set() + this.#observationLayers.push(seen) + try { + const ret = fn() + return {seen, ret} + } + 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) + } + } + + batch = (fn: () => R) => { + this.#batchDepth++ + try { + return fn() + } + finally { + this.#batchDepth-- + if (this.#batchDepth === 0) + this.#flush() + } + } + + #run(fn: () => void) { + if (this.#busy.has(fn)) + throw new Error("circularity forbidden") + this.#busy.add(fn) + try { fn() } + finally { this.#busy.delete(fn) } + } + + #flush() { + while (this.#batchPending.size > 0) { + const pending = [...this.#batchPending] + this.#batchPending.clear() + + for (const fn of pending) + this.#run(fn) + } + } +} + +/** standard global tracker for integrations */ +export const tracker: Tracker = (globalThis as any)[Symbol.for("e280.tracker.2")] ??= new Tracker() + diff --git a/s/test.ts b/s/test.ts index b3e1e94..9f4bc36 100644 --- a/s/test.ts +++ b/s/test.ts @@ -1,15 +1,8 @@ -import {Science} from "@e280/science" +import {science} from "@e280/science" +import nova from "./nova/tracker/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, - tracker, - wait, +await science.run({ + nova, }) From 86a83bf5d0e654e3217abba05341e32e12e3dbbb Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 16:59:00 -0700 Subject: [PATCH 02/48] sketch: nova signals, effect, derived --- s/nova/core/derived.ts | 50 ++++++++++++++++++++++++ s/nova/core/effect.ts | 28 +++++++++++++ s/nova/core/signal.ts | 22 +++++++++++ s/nova/core/test.ts | 63 ++++++++++++++++++++++++++++++ s/nova/core/utils/watch.ts | 10 +++++ s/nova/primitives/signal/signal.ts | 0 s/nova/tracker/test.ts | 22 +++++++++-- s/nova/tracker/tracker.ts | 10 ++--- s/test.ts | 6 ++- 9 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 s/nova/core/derived.ts create mode 100644 s/nova/core/effect.ts create mode 100644 s/nova/core/signal.ts create mode 100644 s/nova/core/test.ts create mode 100644 s/nova/core/utils/watch.ts delete mode 100644 s/nova/primitives/signal/signal.ts diff --git a/s/nova/core/derived.ts b/s/nova/core/derived.ts new file mode 100644 index 0000000..e82efe4 --- /dev/null +++ b/s/nova/core/derived.ts @@ -0,0 +1,50 @@ + +import {watch} from "./utils/watch.js" +import {tracker} from "../tracker/tracker.js" + +export type Derived = { + (): Value + dispose: () => void +} + +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/nova/core/effect.ts b/s/nova/core/effect.ts new file mode 100644 index 0000000..9afbd00 --- /dev/null +++ b/s/nova/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/nova/core/signal.ts b/s/nova/core/signal.ts new file mode 100644 index 0000000..f93a099 --- /dev/null +++ b/s/nova/core/signal.ts @@ -0,0 +1,22 @@ + +import {tracker} from "../tracker/tracker.js" + +export type Signal = { + (): Value + (value: Value): Value +} + +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/nova/core/test.ts b/s/nova/core/test.ts new file mode 100644 index 0000000..0bf3c75 --- /dev/null +++ b/s/nova/core/test.ts @@ -0,0 +1,63 @@ + +import {expect, science, test} from "@e280/science" +import {effect} from "./effect.js" +import {signal} from "./signal.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) + }), + + "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/nova/core/utils/watch.ts b/s/nova/core/utils/watch.ts new file mode 100644 index 0000000..d66b387 --- /dev/null +++ b/s/nova/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/nova/primitives/signal/signal.ts b/s/nova/primitives/signal/signal.ts deleted file mode 100644 index e69de29..0000000 diff --git a/s/nova/tracker/test.ts b/s/nova/tracker/test.ts index b4c46a9..ad59c12 100644 --- a/s/nova/tracker/test.ts +++ b/s/nova/tracker/test.ts @@ -1,5 +1,5 @@ -import {Tracker} from "../tracker.js" +import {Tracker} from "./tracker.js" import {science, test, expect} from "@e280/science" export default science.suite({ @@ -29,13 +29,13 @@ export default science.suite({ const tracker = new Tracker() const item = {} - const {seen, ret} = tracker.observe(() => { + const {seen, value} = tracker.observe(() => { tracker.read(item) return 123 }) expect(seen.has(item)).is(true) - expect(ret).is(123) + expect(value).is(123) }), "nested observe layers are isolated": test(async() => { @@ -126,4 +126,20 @@ export default science.suite({ 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/nova/tracker/tracker.ts b/s/nova/tracker/tracker.ts index beafdbd..b3debdc 100644 --- a/s/nova/tracker/tracker.ts +++ b/s/nova/tracker/tracker.ts @@ -1,12 +1,12 @@ import {GWeakMap} from "@e280/stz" -export type TrackableItem = object | symbol +export type Trackable = object | symbol /** * reactivity integration hub */ -export class Tracker { +export class Tracker { #busy = new Set<() => void>() #observationLayers: Set[] = [] #subscriptions = new GWeakMap void>>() @@ -33,12 +33,12 @@ export class Tracker { } /** collect items that were read during fn */ - observe(fn: () => R) { + observe(fn: () => Value) { const seen = new Set() this.#observationLayers.push(seen) try { - const ret = fn() - return {seen, ret} + const value = fn() + return {seen, value} } finally { this.#observationLayers.pop() diff --git a/s/test.ts b/s/test.ts index 9f4bc36..a902fdb 100644 --- a/s/test.ts +++ b/s/test.ts @@ -1,8 +1,10 @@ import {science} from "@e280/science" -import nova from "./nova/tracker/test.js" +import core from "./nova/core/test.js" +import tracker from "./nova/tracker/test.js" await science.run({ - nova, + tracker, + core, }) From 74cfeb7299f9ca8e8f834b90627a372a13aa414c Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 17:14:21 -0700 Subject: [PATCH 03/48] improve: testing, export batch --- s/nova/core/batch.ts | 5 +++ s/nova/core/index.ts | 6 ++++ s/nova/core/test.ts | 75 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 s/nova/core/batch.ts create mode 100644 s/nova/core/index.ts diff --git a/s/nova/core/batch.ts b/s/nova/core/batch.ts new file mode 100644 index 0000000..dc399dc --- /dev/null +++ b/s/nova/core/batch.ts @@ -0,0 +1,5 @@ + +import {tracker} from "../tracker/tracker.js" + +export const batch = tracker.batch + diff --git a/s/nova/core/index.ts b/s/nova/core/index.ts new file mode 100644 index 0000000..7b1d74d --- /dev/null +++ b/s/nova/core/index.ts @@ -0,0 +1,6 @@ + +export * from "./batch.js" +export * from "./derived.js" +export * from "./effect.js" +export * from "./signal.js" + diff --git a/s/nova/core/test.ts b/s/nova/core/test.ts index 0bf3c75..ad5042e 100644 --- a/s/nova/core/test.ts +++ b/s/nova/core/test.ts @@ -1,7 +1,9 @@ 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({ @@ -35,6 +37,79 @@ export default science.suite({ 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)) From f0cd1a67e0be24b1bd1283ab237ace0ccc9b40f0 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 17:19:39 -0700 Subject: [PATCH 04/48] port: react bindings --- s/nova/bindings/index.ts | 3 +++ s/nova/bindings/react.ts | 52 ++++++++++++++++++++++++++++++++++++++++ s/nova/index.ts | 5 ++++ 3 files changed, 60 insertions(+) create mode 100644 s/nova/bindings/index.ts create mode 100644 s/nova/bindings/react.ts create mode 100644 s/nova/index.ts diff --git a/s/nova/bindings/index.ts b/s/nova/bindings/index.ts new file mode 100644 index 0000000..cc870e2 --- /dev/null +++ b/s/nova/bindings/index.ts @@ -0,0 +1,3 @@ + +export * from "./react.js" + diff --git a/s/nova/bindings/react.ts b/s/nova/bindings/react.ts new file mode 100644 index 0000000..9b5a751 --- /dev/null +++ b/s/nova/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 react(react: { + useEffect: (fn: () => void | (() => void), deps?: unknown[]) => void + useState: (x: X | (() => X)) => [ + value: X, + set: (value: X | ((x: X) => X)) => void + ] + }) { + + const useTracker = (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) => useTracker(() => 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 useTracker(() => $signal()) + return $signal + } + + const useDerived = (formula: () => X) => { + const $derived = useOnce(() => derived(formula)) + void useTracker(() => $derived()) + return $derived + } + + return {component, useTracker, useOnce, useSignal, useDerived} +} + diff --git a/s/nova/index.ts b/s/nova/index.ts new file mode 100644 index 0000000..cd17b03 --- /dev/null +++ b/s/nova/index.ts @@ -0,0 +1,5 @@ + +export * from "./bindings/index.js" +export * from "./core/index.js" +export * from "./tracker/index.js" + From f2a52204f149926b43235e5b0b00aed4a4564be5 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 18:15:21 -0700 Subject: [PATCH 05/48] write: new readme, various tweakums --- s/nova/README.md | 202 +++++++++++++++++++++++++++++++++++++++ s/nova/bindings/react.ts | 12 +-- s/nova/core/derived.ts | 6 +- s/nova/core/index.ts | 1 + s/nova/core/signal.ts | 6 +- s/nova/core/types.ts | 13 +++ 6 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 s/nova/README.md create mode 100644 s/nova/core/types.ts diff --git a/s/nova/README.md b/s/nova/README.md new file mode 100644 index 0000000..541ff86 --- /dev/null +++ b/s/nova/README.md @@ -0,0 +1,202 @@ + +![](https://i.imgur.com/h7FohWa.jpeg) + + + +

+ +# ⛏️ strata + +### get in loser, we're managing state +📦 `npm install @e280/strata` +✨ it's basically about automagically rerendering ui when data changes +🦝 powers auto-reactivity in our view library [@e280/sly](https://github.com/e280/sly) +🧙‍♂️ probably my tenth state management library, lol +🧑‍💻 a project by https://e280.org/ + +🚦 [**#signals,**](#signals) sweet little bundles of state +🪄 [**#tracker,**](#tracker) agnostic reactivity integration hub +⚛️ [**#react,**](#react) optional bindings for react + + + +

+ + + +## 🍋 strata signals +> *reactive bundles of joy* + +```ts +import {signal, derived, effect, batch} from "@e280/strata" +``` + +### 🚦 signal +- **a signal holds a value** + ```ts + const $count = signal(0) + ``` + > *we kinda like the `$` convention for signals* +- **read the signal** + ```ts + $count() // 0 + ``` +- **write the signal** + ```ts + $count(1) + ``` + +### 🚦 derived +- **combine signals like a formula** + ```ts + const $alpha = signal(1) + const $bravo = signal(10) + const $product = derived(() => $alpha() * $bravo()) +- **it automatically updates** + ```ts + $product() // 10 + $alpha(2) + $product() // 🪄 20 + ``` +- **btw,** + deriveds are lazy and don't run the formula fn unless actually demanded. also, they have a `.dispose()` fn you can use to stop them. + +### 🚦 effects +- **effects run when the relevant signals change** + ```ts + effect(() => console.log($count())) + // 1 + // the system detects '$count' is relevant + + $count(2) + // 2 + // when $count is changed, the effect fn is run + ``` +- **btw,** + effects return a dispose fn you can use to stop them. also, you can optionally pass a second parameter fn that receives what the first fn returns, and it's not initially called. + +### 🚦 types +- **`Signal`** — it's a signal fn +- **`Derived`** — it's a derived fn +- **`Valuable`** — could be `Signal` or `Derived` + + + +

+ + + +## 🍋 strata tracker +> *reactivity integration hub* + +```ts +import {tracker} from "@e280/strata/tracker" +``` + +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. + +### 🪄 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) {} + + 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) + + effect(() => console.log($count.get())) + // 1 + + $count.set(2) + // 2 + ``` + +### 🪄 integrate your ui framework for auto-rerendering +- put on your big-kid pants and have your ai agent read the [source code](./tracker/tracker.ts) +- use `tracker.observe` to check what is touched by a fn +- use `tracker.subscribe` to subscribe to the seen items that `observe` returns +- you'll figure it out, lol, or reach out to me on discord + + + +

+ + + +## 🍋 react bindings + +```ts +``` + +### ⚛️ setup your `strata.ts` module +```ts +import * as react from "react" +import {reactBindings} from "@e280/strata" + +export const { + component, + useTracked, + useOnce, + useSignal, + useDerived, +} = 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($count() + 1) + return +}) +``` + +### ⚛️ `useTracked` for a manual hands-on approach (plays nicer with hmr) +```ts +import {signal} from "@e280/strata" +import {useTracked} from "./strata.js" + +const $count = signal(0) + +export const MyCounter = () => { + const count = useTracked(() => $count()) + const add = () => $count($count() + 1) + return +} +``` + +### ⚛️ `useSignal` for local component state (and `useDerived`) +```ts +import {useSignal} from "./strata.js" + +export const MyCounter = () => { + const $count = useSignal(0) + const add = () => $count($count() + 1) + return +} +``` + + + +

+ +## 🧑‍💻 strata is by e280 +free and open source by https://e280.org/ +join us if you're cool and good at dev + diff --git a/s/nova/bindings/react.ts b/s/nova/bindings/react.ts index 9b5a751..b794242 100644 --- a/s/nova/bindings/react.ts +++ b/s/nova/bindings/react.ts @@ -3,7 +3,7 @@ import {signal} from "../core/signal.js" import {derived} from "../core/derived.js" import {tracker} from "../tracker/tracker.js" -export function react(react: { +export function reactBindings(react: { useEffect: (fn: () => void | (() => void), deps?: unknown[]) => void useState: (x: X | (() => X)) => [ value: X, @@ -11,7 +11,7 @@ export function react(react: { ] }) { - const useTracker = (fn: () => X) => { + const useTracked = (fn: () => X) => { const [, setTick] = react.useState(0) const {seen, value} = tracker.observe(fn) @@ -25,7 +25,7 @@ export function react(react: { } const component =

(render: (props: P) => R) => { - const c = (props: P) => useTracker(() => render(props)) + const c = (props: P) => useTracked(() => render(props)) c.displayName = (render as any).displayName ?? render.name ?? "Component" return c } @@ -37,16 +37,16 @@ export function react(react: { const useSignal = (value: X) => { const $signal = useOnce(() => signal(value)) - void useTracker(() => $signal()) + void useTracked(() => $signal()) return $signal } const useDerived = (formula: () => X) => { const $derived = useOnce(() => derived(formula)) - void useTracker(() => $derived()) + void useTracked(() => $derived()) return $derived } - return {component, useTracker, useOnce, useSignal, useDerived} + return {component, useTracked, useOnce, useSignal, useDerived} } diff --git a/s/nova/core/derived.ts b/s/nova/core/derived.ts index e82efe4..06e21dd 100644 --- a/s/nova/core/derived.ts +++ b/s/nova/core/derived.ts @@ -1,12 +1,8 @@ +import {Derived} from "./types.js" import {watch} from "./utils/watch.js" import {tracker} from "../tracker/tracker.js" -export type Derived = { - (): Value - dispose: () => void -} - export function derived(fn: () => Value): Derived { let value!: Value let dirty = true diff --git a/s/nova/core/index.ts b/s/nova/core/index.ts index 7b1d74d..fc337cf 100644 --- a/s/nova/core/index.ts +++ b/s/nova/core/index.ts @@ -3,4 +3,5 @@ export * from "./batch.js" export * from "./derived.js" export * from "./effect.js" export * from "./signal.js" +export * from "./types.js" diff --git a/s/nova/core/signal.ts b/s/nova/core/signal.ts index f93a099..f310d4b 100644 --- a/s/nova/core/signal.ts +++ b/s/nova/core/signal.ts @@ -1,11 +1,7 @@ +import {Signal} from "./types.js" import {tracker} from "../tracker/tracker.js" -export type Signal = { - (): Value - (value: Value): Value -} - export function signal(value: Value): Signal { return function sig() { if (arguments.length === 0) { diff --git a/s/nova/core/types.ts b/s/nova/core/types.ts new file mode 100644 index 0000000..9354196 --- /dev/null +++ b/s/nova/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 + From eca46e0b66bff12324a5afbeada67f44a2fa4742 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 18:24:20 -0700 Subject: [PATCH 06/48] update: readme --- s/nova/README.md | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/s/nova/README.md b/s/nova/README.md index 541ff86..a8e3c59 100644 --- a/s/nova/README.md +++ b/s/nova/README.md @@ -34,16 +34,16 @@ import {signal, derived, effect, batch} from "@e280/strata" ### 🚦 signal - **a signal holds a value** ```ts - const $count = signal(0) + const $count = signal(1) ``` > *we kinda like the `$` convention for signals* - **read the signal** ```ts - $count() // 0 + $count() // 1 ``` - **write the signal** ```ts - $count(1) + $count(2) ``` ### 🚦 derived @@ -52,6 +52,7 @@ import {signal, derived, effect, batch} from "@e280/strata" const $alpha = signal(1) const $bravo = signal(10) const $product = derived(() => $alpha() * $bravo()) + ``` - **it automatically updates** ```ts $product() // 10 @@ -59,22 +60,33 @@ import {signal, derived, effect, batch} from "@e280/strata" $product() // 🪄 20 ``` - **btw,** - deriveds are lazy and don't run the formula fn unless actually demanded. also, they have a `.dispose()` fn you can use to stop them. + deriveds are lazy and don't run the formula fn unless actually demanded (unless an effect is watching them). also, they have a `.dispose()` fn you can use 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(2) - // 2 + $count(3) + // 3 // when $count is changed, the effect fn is run ``` - **btw,** effects return a dispose fn you can use to stop them. also, you can optionally pass a second parameter fn that receives what the first fn returns, and it's not initially called. +### 🚦 batch +- **optimize multiple writes into one fat update** + ```ts + // call downstream effects only once + batch(() => { + $count(4) + $count(5) + $count(6) + }) + ``` + ### 🚦 types - **`Signal`** — it's a signal fn - **`Derived`** — it's a derived fn @@ -124,10 +136,9 @@ this is the inner sanctum of strata. use the tracker to jack into the reactivity ``` ### 🪄 integrate your ui framework for auto-rerendering -- put on your big-kid pants and have your ai agent read the [source code](./tracker/tracker.ts) - use `tracker.observe` to check what is touched by a fn - use `tracker.subscribe` to subscribe to the seen items that `observe` returns -- you'll figure it out, lol, or reach out to me on discord +- see the [source code](./tracker/tracker.ts) From 8a8689ef2d2d5420397461b7d6b9198be8cade62 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 18:50:28 -0700 Subject: [PATCH 07/48] port: wait --- CHANGELOG.md | 11 +++++++++++ s/nova/wait/index.ts | 8 ++++++++ s/nova/wait/parts/get.ts | 26 +++++++++++++++++++++++++ s/nova/wait/parts/is.ts | 8 ++++++++ s/nova/wait/parts/new.ts | 10 ++++++++++ s/nova/wait/parts/select.ts | 21 ++++++++++++++++++++ s/nova/wait/parts/type.ts | 20 +++++++++++++++++++ s/nova/wait/parts/wait.ts | 39 +++++++++++++++++++++++++++++++++++++ s/nova/wait/test.ts | 35 +++++++++++++++++++++++++++++++++ s/test.ts | 2 ++ 10 files changed, 180 insertions(+) create mode 100644 s/nova/wait/index.ts create mode 100644 s/nova/wait/parts/get.ts create mode 100644 s/nova/wait/parts/is.ts create mode 100644 s/nova/wait/parts/new.ts create mode 100644 s/nova/wait/parts/select.ts create mode 100644 s/nova/wait/parts/type.ts create mode 100644 s/nova/wait/parts/wait.ts create mode 100644 s/nova/wait/test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b092fd..a45d9c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ +
+ +## v0.4 + +### v0.4.0 +- 🟥 huge core rewrite +- 🟥 wait + - 🟥 `WaitDone` renamed to `WaitResult` to better match ok/err/result + + +
## v0.3 diff --git a/s/nova/wait/index.ts b/s/nova/wait/index.ts new file mode 100644 index 0000000..a1dc2df --- /dev/null +++ b/s/nova/wait/index.ts @@ -0,0 +1,8 @@ + +export * from "./parts/get.js" +export * from "./parts/is.js" +export * from "./parts/new.js" +export * from "./parts/select.js" +export * from "./parts/wait.js" +export * from "./parts/type.js" + diff --git a/s/nova/wait/parts/get.ts b/s/nova/wait/parts/get.ts new file mode 100644 index 0000000..10caf8a --- /dev/null +++ b/s/nova/wait/parts/get.ts @@ -0,0 +1,26 @@ + +import {getErr, getOk, needErr, needOk} from "@e280/stz" +import {Wait} from "./type.js" + +export function waitGetOk(wait: Wait) { + return wait.done + ? getOk(wait) + : undefined +} + +export function waitNeedOk(wait: Wait) { + if (!wait.done) throw new Error("wait not done") + return needOk(wait) +} + +export function waitGetErr(wait: Wait) { + return wait.done + ? getErr(wait) + : undefined +} + +export function waitNeedErr(wait: Wait) { + if (!wait.done) throw new Error("wait not done") + return needErr(wait) +} + diff --git a/s/nova/wait/parts/is.ts b/s/nova/wait/parts/is.ts new file mode 100644 index 0000000..64a629c --- /dev/null +++ b/s/nova/wait/parts/is.ts @@ -0,0 +1,8 @@ + +import {Wait, WaitDone, 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 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/nova/wait/parts/new.ts b/s/nova/wait/parts/new.ts new file mode 100644 index 0000000..b4d4231 --- /dev/null +++ b/s/nova/wait/parts/new.ts @@ -0,0 +1,10 @@ + +import {Result} from "@e280/stz" +import {Wait} from "./type.js" + +export function newWait(result?: Result): Wait { + return result + ? {done: true, ...result} + : {done: false} +} + diff --git a/s/nova/wait/parts/select.ts b/s/nova/wait/parts/select.ts new file mode 100644 index 0000000..ac73ad9 --- /dev/null +++ b/s/nova/wait/parts/select.ts @@ -0,0 +1,21 @@ + +import {Wait} from "./type.js" +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 + }) { + + if (isWaitPending(wait)) + return select.pending() + + else if (isWaitOk(wait)) + return select.ok(waitNeedOk(wait)) + + else + return select.err(waitNeedErr(wait)) +} + diff --git a/s/nova/wait/parts/type.ts b/s/nova/wait/parts/type.ts new file mode 100644 index 0000000..184cebf --- /dev/null +++ b/s/nova/wait/parts/type.ts @@ -0,0 +1,20 @@ + +import {Err, Ok, Result} from "@e280/stz" +import {Derived} from "../../core/types.js" + +export type WaitPending = {done: false} +export type WaitDone = {done: true} & Result +export type WaitOk = {done: true} & Ok +export type WaitErr = {done: true} & Err + +export type Wait = + | WaitPending + | WaitDone + +export type WaitSignal = + & { + ready: Promise + result: Promise> + } + & Derived> + diff --git a/s/nova/wait/parts/wait.ts b/s/nova/wait/parts/wait.ts new file mode 100644 index 0000000..c2da6b0 --- /dev/null +++ b/s/nova/wait/parts/wait.ts @@ -0,0 +1,39 @@ + +import {attemptAsync, getOk, Result} from "@e280/stz" + +import {newWait} from "./new.js" +import {signal} from "../../core/signal.js" +import {derived} from "../../core/derived.js" +import {Wait, WaitDone, WaitSignal} from "./type.js" + +export function wait( + input: Promise | (() => Promise), + ) { + return waitResult(attemptAsync(input)) +} + +export function waitResult( + input: Promise> | (() => Promise>), + ) { + return waitResultPromise( + (typeof input === "function") + ? input() + : input + ) +} + +function waitResultPromise(promise: Promise>) { + const $wait = signal>(newWait()) + const $derived = derived(() => $wait()) as WaitSignal + + $derived.result = promise.then(result => { + const r: WaitDone = {done: true, ...result} + $wait(r) + return r + }) + + $derived.ready = $derived.result.then(getOk) + + return $derived +} + diff --git a/s/nova/wait/test.ts b/s/nova/wait/test.ts new file mode 100644 index 0000000..264da5d --- /dev/null +++ b/s/nova/wait/test.ts @@ -0,0 +1,35 @@ + +import {nap, needOk} from "@e280/stz" +import {expect, suite, test} from "@e280/science" + +import {wait} from "./parts/wait.js" +import {waitNeedErr, waitNeedOk} from "./parts/get.js" +import {isWaitDone, isWaitErr, isWaitPending} from "./parts/is.js" + +export default suite({ + "wait fn, done": test(async() => { + const $wait = wait(async() => { + await nap() + return 123 + }) + expect(isWaitPending($wait())).is(true) + expect(await $wait.ready).is(123) + expect(needOk(await $wait.result)).is(123) + expect(isWaitDone($wait())).is(true) + expect(waitNeedOk($wait())).is(123) + }), + + "wait fn, failed": test(async() => { + const $wait = wait(async() => { + await nap() + if (!!true) throw new Error("uh oh") + return 123 + }) + expect(isWaitPending($wait())).is(true) + expect(await $wait.ready).is(undefined) + expect((await $wait.result).ok).is(false) + expect(isWaitErr($wait())).is(true) + expect(waitNeedErr($wait()).message).is("uh oh") + }), +}) + diff --git a/s/test.ts b/s/test.ts index a902fdb..1478c81 100644 --- a/s/test.ts +++ b/s/test.ts @@ -1,10 +1,12 @@ import {science} from "@e280/science" import core from "./nova/core/test.js" +import wait from "./nova/wait/test.js" import tracker from "./nova/tracker/test.js" await science.run({ tracker, core, + wait, }) From d1420bf465f94acc31036bdb974982725c9505be Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 18:53:02 -0700 Subject: [PATCH 08/48] tweaks: to wait system and changelog --- CHANGELOG.md | 3 ++- s/nova/wait/index.ts | 2 +- s/nova/wait/parts/is.ts | 4 ++-- s/nova/wait/parts/{new.ts => make.ts} | 2 +- s/nova/wait/parts/type.ts | 14 ++++++-------- s/nova/wait/parts/wait.ts | 8 ++++---- 6 files changed, 16 insertions(+), 17 deletions(-) rename s/nova/wait/parts/{new.ts => make.ts} (59%) diff --git a/CHANGELOG.md b/CHANGELOG.md index a45d9c8..9e7a828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ ### v0.4.0 - 🟥 huge core rewrite - 🟥 wait - - 🟥 `WaitDone` renamed to `WaitResult` to better match ok/err/result + - 🟥 renamed `WaitDone` to `WaitResult` to better match ok/err/result + - 🟥 renamed `newWait` to `makeWait` because i like it more diff --git a/s/nova/wait/index.ts b/s/nova/wait/index.ts index a1dc2df..538790d 100644 --- a/s/nova/wait/index.ts +++ b/s/nova/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/nova/wait/parts/is.ts b/s/nova/wait/parts/is.ts index 64a629c..31710eb 100644 --- a/s/nova/wait/parts/is.ts +++ b/s/nova/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/nova/wait/parts/new.ts b/s/nova/wait/parts/make.ts similarity index 59% rename from s/nova/wait/parts/new.ts rename to s/nova/wait/parts/make.ts index b4d4231..d351e9f 100644 --- a/s/nova/wait/parts/new.ts +++ b/s/nova/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/nova/wait/parts/type.ts b/s/nova/wait/parts/type.ts index 184cebf..49640fc 100644 --- a/s/nova/wait/parts/type.ts +++ b/s/nova/wait/parts/type.ts @@ -3,18 +3,16 @@ import {Err, Ok, Result} from "@e280/stz" 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 WaitSignal = Derived> & { + ready: Promise + result: Promise> +} diff --git a/s/nova/wait/parts/wait.ts b/s/nova/wait/parts/wait.ts index c2da6b0..75ab502 100644 --- a/s/nova/wait/parts/wait.ts +++ b/s/nova/wait/parts/wait.ts @@ -1,10 +1,10 @@ import {attemptAsync, getOk, Result} from "@e280/stz" -import {newWait} from "./new.js" +import {makeWait} from "./make.js" import {signal} from "../../core/signal.js" import {derived} from "../../core/derived.js" -import {Wait, WaitDone, WaitSignal} from "./type.js" +import {Wait, WaitResult, WaitSignal} from "./type.js" export function wait( input: Promise | (() => Promise), @@ -23,11 +23,11 @@ export function waitResult( } function waitResultPromise(promise: Promise>) { - const $wait = signal>(newWait()) + const $wait = signal>(makeWait()) const $derived = derived(() => $wait()) as WaitSignal $derived.result = promise.then(result => { - const r: WaitDone = {done: true, ...result} + const r: WaitResult = {done: true, ...result} $wait(r) return r }) From bc84ba369c5b3f8de6af021159019e31e0770dbd Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 18:59:16 -0700 Subject: [PATCH 09/48] improve: wait naming and readme --- CHANGELOG.md | 2 + s/nova/README.md | 118 ++++++++++++++++++++++++++++++++++++++ s/nova/wait/parts/type.ts | 2 +- s/nova/wait/parts/wait.ts | 12 ++-- 4 files changed, 127 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e7a828..0ad3c98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ - 🟥 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 diff --git a/s/nova/README.md b/s/nova/README.md index a8e3c59..a58d9d0 100644 --- a/s/nova/README.md +++ b/s/nova/README.md @@ -15,6 +15,7 @@ 🧑‍💻 a project by https://e280.org/ 🚦 [**#signals,**](#signals) sweet little bundles of state +⌛ [**#wait,**](#wait) helpers for async operation states 🪄 [**#tracker,**](#tracker) agnostic reactivity integration hub ⚛️ [**#react,**](#react) optional bindings for react @@ -94,6 +95,123 @@ import {signal, derived, effect, batch} from "@e280/strata" +

+ + + +## 🍋 strata wait +> *represent async operations* + +- wait is designed to vibe with [stz#ok](https://github.com/e280/stz#ok) + ```ts + import {ok, err} from "@e280/stz" + ``` + +### ⌛ make some wait +- imports + ```ts + import {makeWait} from "@e280/strata" + ``` +- helpers to create a `Wait` + ```ts + // loading + makeWait() + // {done: false} + + // done, ok + makeWait(ok(123)) + // {done: true, ok: true, value: 123} + + // done, err + makeWait(err("uh oh")) + // {done: true, ok: false, error: "uh oh"} + ``` + +### ⌛ make a magic reactive waiter +- imports + ```ts + import {nap} from "@e280/stz" + import {wait} from "@e280/strata" + ``` +- it's a derived signal (readonly) that tracks the result of an async fn or promise + ```ts + const $wait = wait(async() => { + await nap(100) // do some async stuff + return 123 // return your value + }) + ``` +- read the current state from the signal + ```ts + $wait() + // {done: false} + ``` +- later, when it's ready + ```ts + await $wait.ready + // 123 + // undefined if there was an error + + $wait() + // {done: true, ok: true, value: 123} + ``` + +### ⌛ waitFormal, persnickety belt-and-suspenders mode +- imports + ```ts + import {waitFormal} from "@e280/strata" + ``` +- do formal rigid error handling because you're super strict and serious + ```ts + const $wait = waitFormal(async() => { + if (Math.random() > 0.5) + return ok(123) + + if (Math.random() < 0.01) + return err("unlikely lol") + + else + return err("bad roll") + }) + ``` +- listen for the formal result + ```ts + await $wait.result + // {done: true, ok: true, value: 123} or + // {done: true, ok: false, error: "bad roll"} + ``` +- btw, wait and waitFormal will actually accept a promise if you like + ```ts + const $wait = wait(Promise.resolve(123)) + ``` + +### ⌛ wait helpers +- check the state + ```ts + isWaitPending($wait()) + isWaitDone($wait()) + isWaitOk($wait()) + isWaitErr($wait()) + ``` +- get the finished value or error + ```ts + waitGetOk($wait()) // 123 | undefined + waitNeedOk($wait()) // 123 (or throws an error) + ``` + ```ts + waitGetErr($wait()) // "bad roll" | undefined + waitNeedErr($wait()) // "bad roll" (or throws an error) + ``` +- select based on the state + ```ts + const text = waitSelect($wait(), { + pending: () => "still loading...", + ok: value => `ready: ${value}`, + err: error => `ack! ${error}`, + }) + ``` + + +

diff --git a/s/nova/wait/parts/type.ts b/s/nova/wait/parts/type.ts index 49640fc..b1855b7 100644 --- a/s/nova/wait/parts/type.ts +++ b/s/nova/wait/parts/type.ts @@ -11,7 +11,7 @@ export type Wait = | WaitPending | WaitResult -export type WaitSignal = Derived> & { +export type WaitDerived = Derived> & { ready: Promise result: Promise> } diff --git a/s/nova/wait/parts/wait.ts b/s/nova/wait/parts/wait.ts index 75ab502..89f1b3a 100644 --- a/s/nova/wait/parts/wait.ts +++ b/s/nova/wait/parts/wait.ts @@ -4,27 +4,27 @@ import {attemptAsync, getOk, Result} from "@e280/stz" import {makeWait} from "./make.js" import {signal} from "../../core/signal.js" import {derived} from "../../core/derived.js" -import {Wait, WaitResult, WaitSignal} from "./type.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>) { +function waitFormalPromise(promise: Promise>) { const $wait = signal>(makeWait()) - const $derived = derived(() => $wait()) as WaitSignal + const $derived = derived(() => $wait()) as WaitDerived $derived.result = promise.then(result => { const r: WaitResult = {done: true, ...result} From df7b06f8082aa8edc866b3cedb96f30e5120e713 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 21:46:00 -0700 Subject: [PATCH 10/48] port: prism --- s/nova/prism/chrono/chronicle.ts | 11 + s/nova/prism/chrono/chrono.ts | 95 ++++++++ s/nova/prism/chrono/types.ts | 12 + s/nova/prism/index.ts | 11 + s/nova/prism/lens.ts | 60 +++++ s/nova/prism/prism.ts | 39 ++++ s/nova/prism/test.ts | 337 +++++++++++++++++++++++++++++ s/nova/prism/types.ts | 28 +++ s/nova/prism/utils/cache-cell.ts | 22 ++ s/nova/prism/utils/immute.ts | 8 + s/nova/prism/utils/optic-symbol.ts | 3 + s/nova/prism/vault/local-store.ts | 31 +++ s/nova/prism/vault/types.ts | 22 ++ s/nova/prism/vault/vault.ts | 19 ++ s/test.ts | 2 + 15 files changed, 700 insertions(+) create mode 100644 s/nova/prism/chrono/chronicle.ts create mode 100644 s/nova/prism/chrono/chrono.ts create mode 100644 s/nova/prism/chrono/types.ts create mode 100644 s/nova/prism/index.ts create mode 100644 s/nova/prism/lens.ts create mode 100644 s/nova/prism/prism.ts create mode 100644 s/nova/prism/test.ts create mode 100644 s/nova/prism/types.ts create mode 100644 s/nova/prism/utils/cache-cell.ts create mode 100644 s/nova/prism/utils/immute.ts create mode 100644 s/nova/prism/utils/optic-symbol.ts create mode 100644 s/nova/prism/vault/local-store.ts create mode 100644 s/nova/prism/vault/types.ts create mode 100644 s/nova/prism/vault/vault.ts diff --git a/s/nova/prism/chrono/chronicle.ts b/s/nova/prism/chrono/chronicle.ts new file mode 100644 index 0000000..2926e1f --- /dev/null +++ b/s/nova/prism/chrono/chronicle.ts @@ -0,0 +1,11 @@ + +import {Chronicle} from "./types.js" + +export function chronicle(state: State): Chronicle { + return { + past: [], + present: state, + future: [], + } +} + diff --git a/s/nova/prism/chrono/chrono.ts b/s/nova/prism/chrono/chrono.ts new file mode 100644 index 0000000..449c2ab --- /dev/null +++ b/s/nova/prism/chrono/chrono.ts @@ -0,0 +1,95 @@ + +import {deep} from "@e280/stz" +import {Lens} from "../lens.js" +import {Chronicle} from "./types.js" +import {LensLike} from "../types.js" +import {_optic} from "../utils/optic-symbol.js" + +export class Chrono implements LensLike { + constructor( + public limit: number, + private basis: Lens>, + ) {} + + get chronicle() { + return this.basis.frozen + } + + get state() { + return this.basis.state.present + } + + get frozen() { + return this.basis.frozen.present + } + + get undoable() { + return this.chronicle.past.length + } + + get redoable() { + return this.chronicle.future.length + } + + #mut(chronicle: Chronicle, fn: (state: State) => R) { + const limit = Math.max(0, this.limit) + const snapshot = deep.clone(this.chronicle.present) as State + const result = fn(chronicle.present) + chronicle.past.push(snapshot) + chronicle.past = chronicle.past.slice(-limit) + chronicle.future = [] + return result + } + + /** progress forwards, depositing history into the past */ + mutate(fn: (state: State) => R): R { + return this.basis.mutate(chronicle => this.#mut(chronicle, fn)) + } + + /** step backwards into the past, by n steps */ + undo(n = 1) { + this.basis.mutate(chronicle => { + const snapshots = chronicle.past.slice(-n) + if (snapshots.length >= n) { + const oldPresent = chronicle.present + chronicle.present = snapshots.shift()! + chronicle.past = chronicle.past.slice(0, -n) + chronicle.future.unshift(oldPresent, ...snapshots) + } + }) + } + + /** step forwards into the future, by n steps */ + redo(n = 1) { + this.basis.mutate(chronicle => { + const snapshots = chronicle.future.slice(0, n) + if (snapshots.length >= n) { + const oldPresent = chronicle.present + chronicle.present = snapshots.shift()! + chronicle.past.push(oldPresent, ...snapshots) + chronicle.future = chronicle.future.slice(n) + } + }) + } + + /** wipe past and future snapshots */ + wipe() { + this.basis.mutate(chronicle => { + chronicle.past = [] + chronicle.future = [] + }) + } + + lens(selector: (state: State) => State2) { + const lens = new Lens({ + registerLens: this.basis[_optic].registerLens, + getState: () => selector(this.basis[_optic].getState().present), + mutate: fn => this.basis[_optic].mutate(chronicle => { + return this.#mut(chronicle, state => fn(selector(state))) + }), + }) + this.basis[_optic].registerLens(lens) + return lens + } +} + diff --git a/s/nova/prism/chrono/types.ts b/s/nova/prism/chrono/types.ts new file mode 100644 index 0000000..d5e4e41 --- /dev/null +++ b/s/nova/prism/chrono/types.ts @@ -0,0 +1,12 @@ + +export type Chronicle = { + // [abc] d [efg] + // \ \ \ + // \ \ future + // \ present + // past + past: State[] + present: State + future: State[] +} + diff --git a/s/nova/prism/index.ts b/s/nova/prism/index.ts new file mode 100644 index 0000000..dcbe3d9 --- /dev/null +++ b/s/nova/prism/index.ts @@ -0,0 +1,11 @@ + +export * from "./chrono/chronicle.js" +export * from "./chrono/chrono.js" +export * from "./chrono/types.js" +export * from "./vault/local-store.js" +export * from "./vault/vault.js" +export * from "./vault/types.js" +export * from "./lens.js" +export * from "./prism.js" +export * from "./types.js" + diff --git a/s/nova/prism/lens.ts b/s/nova/prism/lens.ts new file mode 100644 index 0000000..55334c8 --- /dev/null +++ b/s/nova/prism/lens.ts @@ -0,0 +1,60 @@ + +import {deep} from "@e280/stz" +import {immute} from "./utils/immute.js" +import {tracker} from "../tracker/tracker.js" +import {_optic} from "./utils/optic-symbol.js" +import {CacheCell} from "./utils/cache-cell.js" +import {Immutable, LensLike, Optic} from "./types.js" + +/** reactive view into a state prism, with formalized mutations */ +export class Lens implements LensLike { + ;[_optic]: Optic + #previous: State + #stateCache: CacheCell + #frozenCache: CacheCell> + + constructor(optic: Optic) { + this[_optic] = optic + this.#previous = deep.clone(optic.getState()) + this.#stateCache = new CacheCell(() => deep.clone(optic.getState())) + this.#frozenCache = new CacheCell(() => immute(optic.getState())) + } + + 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) + tracker.write(this) + } + } + + /** get a snapshot of the current state. it's typed as mutable, but you should not mutate it. */ + get state() { + tracker.read(this) + return this.#stateCache.get() + } + + /** get an immutable readonly snapshot of the current state. */ + get frozen() { + tracker.read(this) + return this.#frozenCache.get() + } + + mutate(fn: (state: State) => R) { + return this[_optic].mutate(fn) + } + + lens(selector: (state: State) => State2) { + const lens = new Lens({ + getState: () => selector(this[_optic].getState()), + mutate: fn => this[_optic].mutate(state => fn(selector(state))), + registerLens: this[_optic].registerLens, + }) + this[_optic].registerLens(lens) + return lens + } +} + diff --git a/s/nova/prism/prism.ts b/s/nova/prism/prism.ts new file mode 100644 index 0000000..1f603c0 --- /dev/null +++ b/s/nova/prism/prism.ts @@ -0,0 +1,39 @@ + +import {Lens} from "./lens.js" +import {tracker} from "../tracker/tracker.js" + +/** state mangagement source-of-truth */ +export class Prism { + #state: State + #lenses = new Set>() + + constructor(state: State) { + this.#state = state + } + + get() { + tracker.read(this) + return this.#state + } + + set(state: State) { + this.#state = state + 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: fn => { + const result = fn(selector(this.#state)) + this.set(this.#state) + return result + }, + registerLens: lens => this.#lenses.add(lens), + }) + this.#lenses.add(lens) + return lens + } +} + diff --git a/s/nova/prism/test.ts b/s/nova/prism/test.ts new file mode 100644 index 0000000..6be32c3 --- /dev/null +++ b/s/nova/prism/test.ts @@ -0,0 +1,337 @@ + +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 { 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) + 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 + effect(() => { + void prism.get().count + triggered++ + }) + expect(triggered).is(1) + prism.set({count: 2}) + expect(triggered).is(2) + }), + }), + + "lens": suite({ + "get state": test(async() => { + const prism = new Prism({data: {count: 1}}) + const lens = prism.lens(s => s.data) + expect(lens.frozen.count).is(1) + }), + + "state is immutable": test(async() => { + const prism = new Prism({data: {count: 1}}) + const lens = prism.lens(s => s.data) + expect(() => (lens.frozen as any).count++).throws() + }), + + "proper mutation": test(async() => { + const prism = new Prism({data: {count: 1}}) + const lens = prism.lens(s => s.data) + lens.mutate(s => s.count++) + expect(lens.frozen.count).is(2) + 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) + lens.mutate(s => s.data = {count: 2}) + expect(lens.frozen.data.count).is(2) + expect(() => (lens.frozen.data as any).count++).throws() + }), + + "effect reacts": test(async() => { + const prism = new Prism({data: {count: 1}}) + const lens = prism.lens(s => s.data) + let happenings = 0 + effect(() => { + void lens.frozen.count + happenings++ + }) + lens.mutate(s => s.count++) + expect(happenings).is(2) + }), + + "effects can be batched": test(async() => { + const prism = new Prism({data: {count: 1}}) + const lens = prism.lens(s => s.data) + let happenings = 0 + 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 + effect(() => lens.state, () => happenings++) + lens.mutate(s => s.array.push("lmao")) + expect(happenings).is(1) + expect(lens.frozen.array.length).is(2) + }), + + "sync coherence": test(async() => { + const prism = new Prism({data: {count: 1}}) + const lens = prism.lens(s => s.data) + lens.mutate(s => s.count++) + expect(lens.frozen.count).is(2) + lens.mutate(s => s.count++) + expect(lens.frozen.count).is(3) + }), + + "nullable selector": test(async() => { + type S = {a?: {b: {count: number}}} + const prism = new Prism({a: {b: {count: 1}}}) + const lens = prism.lens(s => s.a?.b) + expect(lens.frozen?.count).is(1) + prism.set({a: undefined}) + expect(lens.frozen?.count).is(undefined) + }), + + "deep composition": test(async() => { + const prism = new Prism({a: {b: {count: 1}}}) + const lensA = prism.lens(s => s.a) + const lensB = lensA.lens(s => s.b) + expect(prism.get().a.b.count).is(1) + expect(lensA.frozen.b.count).is(1) + expect(lensB.frozen.count).is(1) + }), + + "deep mutations": test(async() => { + const prism = new Prism({a: {b: {count: 1}}}) + const lensA = prism.lens(s => s.a) + const lensB = lensA.lens(s => s.b) + 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) + 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) + }), + + "outside mutations ignored": test(async() => { + const prism = new Prism({a: {count: 1}, b: {count: 101}}) + const lensA = prism.lens(s => s.a) + const lensB = prism.lens(s => s.b) + let happeningsA = 0 + let happeningsB = 0 + effect(() => lensA.state, () => happeningsA++) + effect(() => lensB.state, () => happeningsB++) + lensA.mutate(s => s.count++) + expect(happeningsA).is(1) + expect(happeningsB).is(0) + }), + + "outside mutations ignored for effects": test(async() => { + const prism = new Prism({a: {count: 1}, b: {count: 101}}) + const lensA = prism.lens(s => s.a) + const lensB = prism.lens(s => s.b) + let happeningsA = 0 + let happeningsB = 0 + effect(() => { + void lensA.frozen.count + happeningsA++ + }) + effect(() => { + void lensB.frozen.count + happeningsB++ + }) + lensA.mutate(s => s.count++) + expect(happeningsA).is(2) + expect(happeningsB).is(1) + }), + }), + + "chrono": suite({ + "get present state": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + expect(chrono.frozen.count).is(1) + }), + + "mutation": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + chrono.mutate(s => s.count++) + expect(chrono.frozen.count).is(2) + chrono.mutate(s => s.count++) + expect(chrono.frozen.count).is(3) + }), + + "undoable/redoable": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + expect(chrono.undoable).is(0) + expect(chrono.redoable).is(0) + chrono.mutate(s => s.count++) + expect(chrono.undoable).is(1) + chrono.mutate(s => s.count++) + expect(chrono.undoable).is(2) + chrono.undo() + expect(chrono.undoable).is(1) + expect(chrono.redoable).is(1) + chrono.undo() + expect(chrono.undoable).is(0) + expect(chrono.redoable).is(2) + chrono.redo() + expect(chrono.undoable).is(1) + expect(chrono.redoable).is(1) + }), + + "undo": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + + chrono.mutate(s => s.count++) + expect(chrono.frozen.count).is(2) + + chrono.undo() + expect(chrono.frozen.count).is(1) + }), + + "sync undo": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + + chrono.mutate(s => s.count++) + expect(chrono.frozen.count).is(2) + + chrono.undo() + expect(chrono.frozen.count).is(1) + }), + + "redo": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + + chrono.mutate(s => s.count++) + expect(chrono.frozen.count).is(2) + + chrono.undo() + expect(chrono.frozen.count).is(1) + + chrono.redo() + expect(chrono.frozen.count).is(2) + }), + + "undo/redo is orderly": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + expect(chrono.frozen.count).is(4) + + chrono.undo() + expect(chrono.frozen.count).is(3) + + chrono.undo() + expect(chrono.frozen.count).is(2) + + chrono.redo() + expect(chrono.frozen.count).is(3) + + chrono.redo() + expect(chrono.frozen.count).is(4) + + chrono.undo() + expect(chrono.frozen.count).is(3) + + chrono.undo() + expect(chrono.frozen.count).is(2) + + chrono.undo() + expect(chrono.frozen.count).is(1) + }), + + "undo nothing does nothing": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + chrono.undo() + expect(chrono.frozen.count).is(1) + }), + + "redo nothing does nothing": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + chrono.redo() + expect(chrono.frozen.count).is(1) + }), + + "undo 2x": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + expect(chrono.frozen.count).is(4) + chrono.undo(2) + expect(chrono.frozen.count).is(2) + }), + + "redo 2x": test(async() => { + const prism = new Prism({data: chronicle({count: 1})}) + const lens = prism.lens(s => s.data) + const chrono = new Chrono(64, lens) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + chrono.mutate(s => s.count++) + expect(chrono.frozen.count).is(4) + chrono.undo(2) + expect(chrono.frozen.count).is(2) + chrono.redo(2) + expect(chrono.frozen.count).is(4) + }), + + "sublens mutations are undoable": test(async() => { + const prism = new Prism({data: chronicle({a: {count: 1}})}) + const chrono = new Chrono(64, prism.lens(s => s.data)) + const sublens = chrono.lens(s => s.a) + expect(sublens.frozen.count).is(1) + sublens.mutate(s => s.count++) + expect(sublens.frozen.count).is(2) + chrono.undo() + expect(sublens.frozen.count).is(1) + }), + }), +}) + diff --git a/s/nova/prism/types.ts b/s/nova/prism/types.ts new file mode 100644 index 0000000..0f00556 --- /dev/null +++ b/s/nova/prism/types.ts @@ -0,0 +1,28 @@ + +import {Lens} from "./lens.js" + +export type LensLike = { + readonly state: State + readonly frozen: Immutable + mutate(fn: (state: State) => R): R + lens(selector: (state: State) => S2): Lens +} + +export type Optic = { + getState: () => State + mutate: (fn: (state: State) => R) => R + registerLens: (lens: Lens) => void +} + +export type Immutable = + T extends (...args: any[]) => any ? T : + T extends readonly any[] ? ReadonlyArray> : + T extends object ? { readonly [K in keyof T]: Immutable } : + T + +export type Mutable = + T extends (...args: any[]) => any ? T : + T extends ReadonlyArray ? Mutable[] : + T extends object ? { -readonly [K in keyof T]: Mutable } : + T + diff --git a/s/nova/prism/utils/cache-cell.ts b/s/nova/prism/utils/cache-cell.ts new file mode 100644 index 0000000..db06c86 --- /dev/null +++ b/s/nova/prism/utils/cache-cell.ts @@ -0,0 +1,22 @@ + +export class CacheCell { + #dirty = false + #value: R + + constructor(private calculate: () => R) { + this.#value = calculate() + } + + get() { + if (this.#dirty) { + this.#dirty = false + this.#value = this.calculate() + } + return this.#value + } + + invalidate() { + this.#dirty = true + } +} + diff --git a/s/nova/prism/utils/immute.ts b/s/nova/prism/utils/immute.ts new file mode 100644 index 0000000..4e11d5a --- /dev/null +++ b/s/nova/prism/utils/immute.ts @@ -0,0 +1,8 @@ + +import {deep} from "@e280/stz" +import {Immutable} from "../types.js" + +export function immute(s: S) { + return deep.freeze(deep.clone(s)) as Immutable +} + diff --git a/s/nova/prism/utils/optic-symbol.ts b/s/nova/prism/utils/optic-symbol.ts new file mode 100644 index 0000000..3fd0437 --- /dev/null +++ b/s/nova/prism/utils/optic-symbol.ts @@ -0,0 +1,3 @@ + +export const _optic = Symbol("optic") + diff --git a/s/nova/prism/vault/local-store.ts b/s/nova/prism/vault/local-store.ts new file mode 100644 index 0000000..83238bb --- /dev/null +++ b/s/nova/prism/vault/local-store.ts @@ -0,0 +1,31 @@ + +import {Cubby} from "./types.js" + +export class LocalStore implements Cubby { + constructor( + private key: string, + private storage: Storage = window.localStorage, + ) {} + + async get() { + const json = this.storage.getItem(this.key) + return json + ? JSON.parse(json) + : undefined + } + + async set(data: X) { + const json = JSON.stringify(data) + this.storage.setItem(this.key, json) + } + + onStorageEvent(fn: () => void) { + const listener = (event: StorageEvent) => { + if (event.storageArea === this.storage && event.key === this.key) + fn() + } + window.addEventListener("storage", listener) + return () => window.removeEventListener("storage", listener) + } +} + diff --git a/s/nova/prism/vault/types.ts b/s/nova/prism/vault/types.ts new file mode 100644 index 0000000..1de264a --- /dev/null +++ b/s/nova/prism/vault/types.ts @@ -0,0 +1,22 @@ + +import {Prism} from "../prism.js" + +export type Versioned = { + state: State + version: number +} + +export type Cubby = { + get(): Promise + set(data: X | undefined): Promise +} + +/** @deprecated renamed to `Cubby` */ +export type EzStore = Cubby + +export type VaultOptions = { + version: number + prism: Prism + store: Cubby> +} + diff --git a/s/nova/prism/vault/vault.ts b/s/nova/prism/vault/vault.ts new file mode 100644 index 0000000..c2b21c2 --- /dev/null +++ b/s/nova/prism/vault/vault.ts @@ -0,0 +1,19 @@ + +import {VaultOptions} from "./types.js" + +export class Vault { + constructor(private options: VaultOptions) {} + + load = async() => { + const {store, version, prism} = this.options + const pickle = await store.get() + if (pickle && pickle.version === version) + prism.set(pickle.state) + } + + save = async() => { + const {store, version, prism} = this.options + await store.set({version, state: prism.get()}) + } +} + diff --git a/s/test.ts b/s/test.ts index 1478c81..22a49c3 100644 --- a/s/test.ts +++ b/s/test.ts @@ -2,11 +2,13 @@ import {science} from "@e280/science" import core from "./nova/core/test.js" import wait from "./nova/wait/test.js" +import prism from "./nova/prism/test.js" import tracker from "./nova/tracker/test.js" await science.run({ tracker, core, wait, + prism, }) From d2bd1a679cacbe2539c9c19976a9468b9a9fb7cc Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 22:14:39 -0700 Subject: [PATCH 11/48] add: prism readme --- s/nova/README.md | 138 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/s/nova/README.md b/s/nova/README.md index a58d9d0..a87cc9b 100644 --- a/s/nova/README.md +++ b/s/nova/README.md @@ -16,6 +16,7 @@ 🚦 [**#signals,**](#signals) sweet little bundles of state ⌛ [**#wait,**](#wait) helpers for async operation states +🔮 [**#prism,**](#prism) bigger centralized state trees 🪄 [**#tracker,**](#tracker) agnostic reactivity integration hub ⚛️ [**#react,**](#react) optional bindings for react @@ -212,6 +213,143 @@ import {signal, derived, effect, batch} from "@e280/strata" +

+ + + +## 🍋 strata prism +> *persistent app-level state* + +- single-source-of-truth state tree +- no spooky-dookie proxy magic — just god's honest javascript +- immutable except for `mutate(fn)` calls +- use many lenses, efficient reactivity +- chrono provides undo/redo history +- persistence, localstorage, cross-tab sync + +### 🔮 prism and lenses +- **import prism** + ```ts + import {Prism} from "@e280/strata" + ``` +- **prism is a state tree** + ```ts + const prism = new Prism({ + snacks: { + peanuts: 8, + bag: ["popcorn", "butter"], + person: { + name: "chase", + incredi: true, + }, + }, + }) + ``` +- **create lenses, which are views into state subtrees** + ```ts + const snacks = prism.lens(state => state.snacks) + const person = snacks.lens(state => state.person) + ``` + - you can lens another lens +- **lenses provide snapshot access to state** + ```ts + // .state is a mutable snapshot with relaxed typings + snacks.state.peanuts // 8 + person.state.name // "chase" + // ⛔ casual mutations ignored + + // .frozen is an immutable snapshot with strict typings + snacks.frozen.peanuts // 8 + snacks.frozen.peanuts++ + // ⛔ casual mutations throw errors + ``` +- **only formal mutations can actually change state** + ```ts + snacks.mutate(state => state.peanuts++) + // ✅ formal mutations to change state + + snacks.state.peanuts // 9 + ``` +- **array mutations are unironically based, actually** + ```ts + snacks.mutate(state => state.bag.push("salt")) + ``` + +### 🔮 chrono for time travel +- **import stuff** + ```ts + import {Chrono, chronicle} from "@e280/strata" + ``` +- **create a chronicle in your state** + ```ts + const prism = new Prism({ + + // chronicle stores history + // 👇 + snacks: chronicle({ + peanuts: 8, + bag: ["popcorn", "butter"], + person: { + name: "chase", + incredi: true, + }, + }), + }) + ``` + - *big-brain moment:* the whole chronicle *itself* is stored in the state.. serializable.. think persistence — user can close their project, reopen, and their undo/redo history is still chillin' — *brat girl summer* +- **create a chrono-wrapped lens to interact with your chronicle** + ```ts + const snacks = new Chrono(64, prism.lens(state => state.snacks)) + // 👆 + // how many past snapshots to store + ``` +- **mutations will advance history,** and undo/redo works + ```ts + snacks.mutate(s => s.peanuts = 101) + + snacks.undo() + // back to 8 peanuts + + snacks.redo() + // forward to 101 peanuts + ``` +- **check how many undoable or redoable steps are available** + ```ts + snacks.undoable // 1 + snacks.redoable // 0 + ``` +- **you can make sub-lenses of a chrono,** all their mutations advance history too +- **plz pinky-swear right now,** that you won't create a chrono under a lens under another chrono 💀 + +### 🔮 persistence to localStorage +- **import prism** + ```ts + import {Vault, LocalStore} from "@e280/strata" + ``` +- **create a local storage store** + ```ts + const store = new LocalStore("myAppState") + ``` +- **make a vault for your prism** + ```ts + const vault = new Vault({ + prism, + store, + version: 1, // 👈 bump this when you break your state schema! + }) + ``` + - `store` type is compatible with [`@e280/kv`](https://github.com/e280/kv) +- **cross-tab sync (load on storage events)** + ```ts + store.onStorageEvent(vault.load) + ``` +- **initial load** + ```ts + await vault.load() + ``` + + +

From 223fd44e8baedce815c65f27a24a069d541c8327 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 22:14:52 -0700 Subject: [PATCH 12/48] update: changelog --- CHANGELOG.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ad3c98..e923b7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,20 @@ ## v0.4 ### v0.4.0 -- 🟥 huge core rewrite -- 🟥 wait +- 🟥 **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 From ef31340ef4786cbda4791200d618a1c7fa93ca6f Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 22:20:39 -0700 Subject: [PATCH 13/48] add: r map and set --- s/nova/core/index.ts | 2 ++ s/nova/core/r/map.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++ s/nova/core/r/set.ts | 44 ++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 s/nova/core/r/map.ts create mode 100644 s/nova/core/r/set.ts diff --git a/s/nova/core/index.ts b/s/nova/core/index.ts index fc337cf..1a13ccd 100644 --- a/s/nova/core/index.ts +++ b/s/nova/core/index.ts @@ -1,4 +1,6 @@ +export * from "./r/map.js" +export * from "./r/set.js" export * from "./batch.js" export * from "./derived.js" export * from "./effect.js" diff --git a/s/nova/core/r/map.ts b/s/nova/core/r/map.ts new file mode 100644 index 0000000..d83af90 --- /dev/null +++ b/s/nova/core/r/map.ts @@ -0,0 +1,64 @@ + +import {GMap} from "@e280/stz" +import {tracker} from "../../tracker/tracker.js" + +export class RMap extends GMap { + get size() { + tracker.read(this) + return super.size + } + + ;[Symbol.iterator]() { + tracker.read(this) + return super[Symbol.iterator]() + } + + keys() { + tracker.read(this) + return super.keys() + } + + values() { + tracker.read(this) + return super.values() + } + + entries() { + tracker.read(this) + return super.entries() + } + + forEach(callbackFn: (value: V, key: K, map: Map) => void) { + tracker.read(this) + return super.forEach(callbackFn) + } + + has(key: K) { + tracker.read(this) + return super.has(key) + } + + get(key: K) { + tracker.read(this) + return super.get(key) + } + + set(key: K, value: V) { + const r = super.set(key, value) + tracker.write(this) + return r + } + + delete(key: K) { + const r = super.delete(key) + tracker.write(this) + return r + } + + clear() { + super.clear() + tracker.write(this) + return this + } +} + diff --git a/s/nova/core/r/set.ts b/s/nova/core/r/set.ts new file mode 100644 index 0000000..27e1b87 --- /dev/null +++ b/s/nova/core/r/set.ts @@ -0,0 +1,44 @@ + +import {GSet} from "@e280/stz" +import {tracker} from "../../tracker/tracker.js" + +export class RSet extends GSet { + get size() { + tracker.read(this) + return super.size + } + + ;[Symbol.iterator]() { + tracker.read(this) + return super[Symbol.iterator]() + } + + values() { + tracker.read(this) + return super.values() + } + + has(item: T) { + tracker.read(this) + return super.has(item) + } + + add(item: T) { + super.add(item) + tracker.write(this) + return this + } + + delete(item: T) { + const r = super.delete(item) + tracker.write(this) + return r + } + + clear() { + super.clear() + tracker.write(this) + return this + } +} + From d8f231c592520d725e3d4d858aed0edd04641303 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 23:14:07 -0700 Subject: [PATCH 14/48] improve: wait readme --- README.md | 104 ++++++++++++++---------------------- s/nova/wait/parts/select.ts | 18 ++++--- 2 files changed, 52 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 77293fa..e3bd716 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 @@ -269,99 +269,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 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"} - ``` +***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. -### ⌛ 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 - ```ts - await $wait.result - // {done: true, ok: true, value: 123} or - // {done: true, ok: false, error: "bad roll"} - ``` -- btw, wait and waitResult will actually accept a promise if you like + +### ⌛ wait, there's more +- maker ```ts - const $wait = wait(Promise.resolve(123)) + makeWait() // pending + makeWait(ok(123)) + makeWait(err("uh oh")) ``` - -### ⌛ wait helpers -- check the state +- status checkers ```ts isWaitPending($wait()) - isWaitDone($wait()) + isWaitDone($wait()) // ok or err isWaitOk($wait()) isWaitErr($wait()) ``` -- get the finished value or error +- value grabbers ```ts waitGetOk($wait()) // 123 | undefined waitNeedOk($wait()) // 123 (or throws an error) @@ -370,7 +346,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...", diff --git a/s/nova/wait/parts/select.ts b/s/nova/wait/parts/select.ts index ac73ad9..6e9fa5f 100644 --- a/s/nova/wait/parts/select.ts +++ b/s/nova/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)) } From 4c36e8b69e88e6e249af3336fe191e51e9a2b4ec Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 23:18:25 -0700 Subject: [PATCH 15/48] fix: new readme with new wait docs --- s/nova/README.md | 212 +++++++++++++++++++++-------------------------- 1 file changed, 94 insertions(+), 118 deletions(-) diff --git a/s/nova/README.md b/s/nova/README.md index a87cc9b..005bca4 100644 --- a/s/nova/README.md +++ b/s/nova/README.md @@ -15,8 +15,8 @@ 🧑‍💻 a project by https://e280.org/ 🚦 [**#signals,**](#signals) sweet little bundles of state -⌛ [**#wait,**](#wait) helpers for async operation states 🔮 [**#prism,**](#prism) bigger centralized state trees +⌛ [**#wait,**](#wait) async state helpers *(think loading spinners)* 🪄 [**#tracker,**](#tracker) agnostic reactivity integration hub ⚛️ [**#react,**](#react) optional bindings for react @@ -96,123 +96,6 @@ import {signal, derived, effect, batch} from "@e280/strata" -

- - - -## 🍋 strata wait -> *represent async operations* - -- wait is designed to vibe with [stz#ok](https://github.com/e280/stz#ok) - ```ts - import {ok, err} from "@e280/stz" - ``` - -### ⌛ make some wait -- imports - ```ts - import {makeWait} from "@e280/strata" - ``` -- helpers to create a `Wait` - ```ts - // loading - makeWait() - // {done: false} - - // done, ok - makeWait(ok(123)) - // {done: true, ok: true, value: 123} - - // done, err - makeWait(err("uh oh")) - // {done: true, ok: false, error: "uh oh"} - ``` - -### ⌛ make a magic reactive waiter -- imports - ```ts - import {nap} from "@e280/stz" - import {wait} from "@e280/strata" - ``` -- it's a derived signal (readonly) that tracks the result of an async fn or promise - ```ts - const $wait = wait(async() => { - await nap(100) // do some async stuff - return 123 // return your value - }) - ``` -- read the current state from the signal - ```ts - $wait() - // {done: false} - ``` -- later, when it's ready - ```ts - await $wait.ready - // 123 - // undefined if there was an error - - $wait() - // {done: true, ok: true, value: 123} - ``` - -### ⌛ waitFormal, persnickety belt-and-suspenders mode -- imports - ```ts - import {waitFormal} from "@e280/strata" - ``` -- do formal rigid error handling because you're super strict and serious - ```ts - const $wait = waitFormal(async() => { - if (Math.random() > 0.5) - return ok(123) - - if (Math.random() < 0.01) - return err("unlikely lol") - - else - return err("bad roll") - }) - ``` -- listen for the formal result - ```ts - await $wait.result - // {done: true, ok: true, value: 123} or - // {done: true, ok: false, error: "bad roll"} - ``` -- btw, wait and waitFormal will actually accept a promise if you like - ```ts - const $wait = wait(Promise.resolve(123)) - ``` - -### ⌛ wait helpers -- check the state - ```ts - isWaitPending($wait()) - isWaitDone($wait()) - isWaitOk($wait()) - isWaitErr($wait()) - ``` -- get the finished value or error - ```ts - waitGetOk($wait()) // 123 | undefined - waitNeedOk($wait()) // 123 (or throws an error) - ``` - ```ts - waitGetErr($wait()) // "bad roll" | undefined - waitNeedErr($wait()) // "bad roll" (or throws an error) - ``` -- select based on the state - ```ts - const text = waitSelect($wait(), { - pending: () => "still loading...", - ok: value => `ready: ${value}`, - err: error => `ack! ${error}`, - }) - ``` - - -

@@ -350,6 +233,99 @@ import {signal, derived, effect, batch} from "@e280/strata" +

+ + + +## 🍋 strata wait +> *tiny async state helpers* + +***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. + +### ⌛ good things come to those who wait +- **import stuff** + ```ts + import {ok, err, nap} from "@e280/stz" + import {wait, waitFormal} from "@e280/strata" + ``` +- **wrap any async operation in a fancy wait** + ```ts + // wrap any async operation in a fancy wait + const $wait = wait(async() => { + await nap(100) + if (Math.random() > 0.5) return 123 + else throw new Error("bad luck!") + }) + ``` + - btw you can pass a promise instead of an async fn +- **check if it's done** + ```ts + console.log($wait().done) + // false -- sorry bro, its not ready yet + ``` +- **okay, we can actually await for the result** + ```ts + const result = await $wait.result + + if (result.ok) + console.log(result.value) + // 123 + else + console.error(result.error) + // Error: bad luck! + ``` + +### ⌛ waitFormal is persnickety belt-and-suspenders mode +- **you can get super explicit about the types** + ```ts + const $wait = waitFormal(async() => { + if (Math.random() > 0.5) + return ok(123) + + if (Math.random() < 0.01) + return err("unlucky") + + else + return err("bad roll") + }) + ``` + +### ⌛ wait, there's more +- maker + ```ts + makeWait() // pending + makeWait(ok(123)) + makeWait(err("uh oh")) + ``` +- status checkers + ```ts + isWaitPending($wait()) + isWaitDone($wait()) // ok or err + isWaitOk($wait()) + isWaitErr($wait()) + ``` +- value grabbers + ```ts + waitGetOk($wait()) // 123 | undefined + waitNeedOk($wait()) // 123 (or throws an error) + ``` + ```ts + waitGetErr($wait()) // "bad roll" | undefined + waitNeedErr($wait()) // "bad roll" (or throws an error) + ``` +- quick selector + ```ts + const text = waitSelect($wait(), { + pending: () => "still loading...", + ok: value => `ready: ${value}`, + err: error => `ack! ${error}`, + }) + ``` + + +

From 7e4c4952128f603881823e4793922ca6ddedd7cd Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 23:26:24 -0700 Subject: [PATCH 16/48] improve: readme --- s/nova/README.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/s/nova/README.md b/s/nova/README.md index 005bca4..1b9994c 100644 --- a/s/nova/README.md +++ b/s/nova/README.md @@ -62,7 +62,8 @@ import {signal, derived, effect, batch} from "@e280/strata" $product() // 🪄 20 ``` - **btw,** - deriveds are lazy and don't run the formula fn unless actually demanded (unless an effect is watching them). also, they have a `.dispose()` fn you can use to stop them. + 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** @@ -76,7 +77,8 @@ import {signal, derived, effect, batch} from "@e280/strata" // when $count is changed, the effect fn is run ``` - **btw,** - effects return a dispose fn you can use to stop them. also, you can optionally pass a second parameter fn that receives what the first fn returns, and it's not initially called. + 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. ### 🚦 batch - **optimize multiple writes into one fat update** @@ -134,17 +136,21 @@ import {signal, derived, effect, batch} 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" - // ⛔ casual mutations ignored - // .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++ - // ⛔ casual mutations throw errors + // ⛔ attempted frozen mutation: throw errors ``` - **only formal mutations can actually change state** ```ts From dfe475dea8abb52c626bcf6ab4eb611771e77cf9 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 23:31:35 -0700 Subject: [PATCH 17/48] adopt: nova as the real codebase --- README.md | 237 ++++++--------- s/{nova => }/bindings/index.ts | 0 s/{nova => }/bindings/react.ts | 0 s/{nova => }/core/batch.ts | 0 s/{nova => }/core/derived.ts | 0 s/{nova => }/core/effect.ts | 0 s/{nova => }/core/index.ts | 0 s/{nova => }/core/r/map.ts | 0 s/{nova => }/core/r/set.ts | 0 s/{nova => }/core/signal.ts | 0 s/{nova => }/core/test.ts | 0 s/{nova => }/core/types.ts | 0 s/{nova => }/core/utils/watch.ts | 0 s/index.ts | 3 +- s/nova/README.md | 451 ----------------------------- s/nova/index.ts | 5 - s/nova/prism/chrono/chronicle.ts | 11 - s/nova/prism/chrono/chrono.ts | 95 ------ s/nova/prism/chrono/types.ts | 12 - s/nova/prism/index.ts | 11 - s/nova/prism/lens.ts | 60 ---- s/nova/prism/prism.ts | 39 --- s/nova/prism/test.ts | 337 --------------------- s/nova/prism/types.ts | 28 -- s/nova/prism/utils/cache-cell.ts | 22 -- s/nova/prism/utils/immute.ts | 8 - s/nova/prism/utils/optic-symbol.ts | 3 - s/nova/prism/vault/local-store.ts | 31 -- s/nova/prism/vault/types.ts | 22 -- s/nova/prism/vault/vault.ts | 19 -- s/nova/tracker/index.ts | 3 - s/nova/tracker/test.ts | 145 ---------- s/nova/tracker/tracker.ts | 92 ------ s/nova/wait/index.ts | 8 - s/nova/wait/parts/get.ts | 26 -- s/nova/wait/parts/is.ts | 8 - s/nova/wait/parts/select.ts | 27 -- s/nova/wait/parts/type.ts | 18 -- s/nova/wait/parts/wait.ts | 39 --- s/nova/wait/test.ts | 35 --- s/prism/chrono/chrono.ts | 14 +- s/prism/lens.ts | 20 +- s/prism/prism.ts | 17 +- s/prism/test.ts | 142 +++++---- s/prism/types.ts | 4 +- s/prism/vault/vault.ts | 2 +- s/signals/derived/class.ts | 34 --- s/signals/derived/fn.ts | 35 --- s/signals/derived/test.ts | 122 -------- s/signals/effect/effect.ts | 11 - s/signals/effect/test.ts | 172 ----------- s/signals/effect/watch.ts | 32 -- s/signals/index.ts | 18 -- s/signals/lazy/class.ts | 74 ----- s/signals/lazy/fn.ts | 22 -- s/signals/lazy/test.ts | 75 ----- s/signals/r/map.ts | 63 ---- s/signals/r/set.ts | 44 --- s/signals/signal/class.ts | 77 ----- s/signals/signal/fn.ts | 31 -- s/signals/signal/test.ts | 112 ------- s/signals/test.ts | 15 - s/signals/types.ts | 11 - s/signals/utils/default-compare.ts | 5 - s/signals/utils/symbols.ts | 9 - s/test.ts | 8 +- s/tracker/bindings/react.ts | 53 ---- s/tracker/index.ts | 1 - s/tracker/test.ts | 149 ++++++++-- s/tracker/tracker.ts | 114 +++++--- s/wait/index.ts | 2 +- s/wait/parts/is.ts | 4 +- s/{nova => }/wait/parts/make.ts | 0 s/wait/parts/new.ts | 10 - s/wait/parts/select.ts | 18 +- s/wait/parts/type.ts | 16 +- s/wait/parts/wait.ts | 25 +- 77 files changed, 423 insertions(+), 2933 deletions(-) rename s/{nova => }/bindings/index.ts (100%) rename s/{nova => }/bindings/react.ts (100%) rename s/{nova => }/core/batch.ts (100%) rename s/{nova => }/core/derived.ts (100%) rename s/{nova => }/core/effect.ts (100%) rename s/{nova => }/core/index.ts (100%) rename s/{nova => }/core/r/map.ts (100%) rename s/{nova => }/core/r/set.ts (100%) rename s/{nova => }/core/signal.ts (100%) rename s/{nova => }/core/test.ts (100%) rename s/{nova => }/core/types.ts (100%) rename s/{nova => }/core/utils/watch.ts (100%) delete mode 100644 s/nova/README.md delete mode 100644 s/nova/index.ts delete mode 100644 s/nova/prism/chrono/chronicle.ts delete mode 100644 s/nova/prism/chrono/chrono.ts delete mode 100644 s/nova/prism/chrono/types.ts delete mode 100644 s/nova/prism/index.ts delete mode 100644 s/nova/prism/lens.ts delete mode 100644 s/nova/prism/prism.ts delete mode 100644 s/nova/prism/test.ts delete mode 100644 s/nova/prism/types.ts delete mode 100644 s/nova/prism/utils/cache-cell.ts delete mode 100644 s/nova/prism/utils/immute.ts delete mode 100644 s/nova/prism/utils/optic-symbol.ts delete mode 100644 s/nova/prism/vault/local-store.ts delete mode 100644 s/nova/prism/vault/types.ts delete mode 100644 s/nova/prism/vault/vault.ts delete mode 100644 s/nova/tracker/index.ts delete mode 100644 s/nova/tracker/test.ts delete mode 100644 s/nova/tracker/tracker.ts delete mode 100644 s/nova/wait/index.ts delete mode 100644 s/nova/wait/parts/get.ts delete mode 100644 s/nova/wait/parts/is.ts delete mode 100644 s/nova/wait/parts/select.ts delete mode 100644 s/nova/wait/parts/type.ts delete mode 100644 s/nova/wait/parts/wait.ts delete mode 100644 s/nova/wait/test.ts delete mode 100644 s/signals/derived/class.ts delete mode 100644 s/signals/derived/fn.ts delete mode 100644 s/signals/derived/test.ts delete mode 100644 s/signals/effect/effect.ts delete mode 100644 s/signals/effect/test.ts delete mode 100644 s/signals/effect/watch.ts delete mode 100644 s/signals/index.ts delete mode 100644 s/signals/lazy/class.ts delete mode 100644 s/signals/lazy/fn.ts delete mode 100644 s/signals/lazy/test.ts delete mode 100644 s/signals/r/map.ts delete mode 100644 s/signals/r/set.ts delete mode 100644 s/signals/signal/class.ts delete mode 100644 s/signals/signal/fn.ts delete mode 100644 s/signals/signal/test.ts delete mode 100644 s/signals/test.ts delete mode 100644 s/signals/types.ts delete mode 100644 s/signals/utils/default-compare.ts delete mode 100644 s/signals/utils/symbols.ts delete mode 100644 s/tracker/bindings/react.ts rename s/{nova => }/wait/parts/make.ts (100%) delete mode 100644 s/wait/parts/new.ts diff --git a/README.md b/README.md index e3bd716..1b9994c 100644 --- a/README.md +++ b/README.md @@ -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** - ```ts - $count() // read - await $count(2) // write - ``` -- **signal get/set syntax** +### 🚦 derived +- **combine signals like a formula** ```ts - $count.get() // read - await $count.set(2) // write + const $alpha = signal(1) + const $bravo = signal(10) + const $product = derived(() => $alpha() * $bravo()) ``` -- **signal .value accessor syntax** +- **it automatically updates** ```ts - $count.value // read - $count.value = 2 // write - ``` - value pattern is super nice for these vibes - ```ts - $count.value++ - $count.value += 1 + $product() // 10 + $alpha(2) + $product() // 🪄 20 ``` +- **btw,** + deriveds are lazy, only run the fn when demanded. + also, they have a `.dispose()` fn if you need to stop them. ### 🚦 effects - **effects run when the relevant signals change** ```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 @@ -368,71 +343,40 @@ it's like, for your ui, showing little loading spinners and branching when stuff 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) +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. - // a set of items that were accessed during myRenderFn - seen - - // the value returned by myRenderFn - result - ``` -- it's a good idea to debounce your rerender fn - ```ts - import {microbounce} from "@e280/stz" - const myDebouncedRerenderFn = microbounce(myRerenderFn) - ``` -- `tracker.subscribe` to respond to changes - ```ts - const stoppers: (() => void)[] = [] - - // loop over every seen item - for (const item of seen) { - - // 🪄 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) @@ -442,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/nova/bindings/index.ts b/s/bindings/index.ts similarity index 100% rename from s/nova/bindings/index.ts rename to s/bindings/index.ts diff --git a/s/nova/bindings/react.ts b/s/bindings/react.ts similarity index 100% rename from s/nova/bindings/react.ts rename to s/bindings/react.ts diff --git a/s/nova/core/batch.ts b/s/core/batch.ts similarity index 100% rename from s/nova/core/batch.ts rename to s/core/batch.ts diff --git a/s/nova/core/derived.ts b/s/core/derived.ts similarity index 100% rename from s/nova/core/derived.ts rename to s/core/derived.ts diff --git a/s/nova/core/effect.ts b/s/core/effect.ts similarity index 100% rename from s/nova/core/effect.ts rename to s/core/effect.ts diff --git a/s/nova/core/index.ts b/s/core/index.ts similarity index 100% rename from s/nova/core/index.ts rename to s/core/index.ts diff --git a/s/nova/core/r/map.ts b/s/core/r/map.ts similarity index 100% rename from s/nova/core/r/map.ts rename to s/core/r/map.ts diff --git a/s/nova/core/r/set.ts b/s/core/r/set.ts similarity index 100% rename from s/nova/core/r/set.ts rename to s/core/r/set.ts diff --git a/s/nova/core/signal.ts b/s/core/signal.ts similarity index 100% rename from s/nova/core/signal.ts rename to s/core/signal.ts diff --git a/s/nova/core/test.ts b/s/core/test.ts similarity index 100% rename from s/nova/core/test.ts rename to s/core/test.ts diff --git a/s/nova/core/types.ts b/s/core/types.ts similarity index 100% rename from s/nova/core/types.ts rename to s/core/types.ts diff --git a/s/nova/core/utils/watch.ts b/s/core/utils/watch.ts similarity index 100% rename from s/nova/core/utils/watch.ts rename to s/core/utils/watch.ts 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/nova/README.md b/s/nova/README.md deleted file mode 100644 index 1b9994c..0000000 --- a/s/nova/README.md +++ /dev/null @@ -1,451 +0,0 @@ - -![](https://i.imgur.com/h7FohWa.jpeg) - - - -

- -# ⛏️ strata - -### get in loser, we're managing state -📦 `npm install @e280/strata` -✨ it's basically about automagically rerendering ui when data changes -🦝 powers auto-reactivity in our view library [@e280/sly](https://github.com/e280/sly) -🧙‍♂️ probably my tenth state management library, lol -🧑‍💻 a project by https://e280.org/ - -🚦 [**#signals,**](#signals) sweet little bundles of state -🔮 [**#prism,**](#prism) bigger centralized state trees -⌛ [**#wait,**](#wait) async state helpers *(think loading spinners)* -🪄 [**#tracker,**](#tracker) agnostic reactivity integration hub -⚛️ [**#react,**](#react) optional bindings for react - - - -

- - - -## 🍋 strata signals -> *reactive bundles of joy* - -```ts -import {signal, derived, effect, batch} from "@e280/strata" -``` - -### 🚦 signal -- **a signal holds a value** - ```ts - const $count = signal(1) - ``` - > *we kinda like the `$` convention for signals* -- **read the signal** - ```ts - $count() // 1 - ``` -- **write the signal** - ```ts - $count(2) - ``` - -### 🚦 derived -- **combine signals like a formula** - ```ts - const $alpha = signal(1) - const $bravo = signal(10) - const $product = derived(() => $alpha() * $bravo()) - ``` -- **it automatically updates** - ```ts - $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())) - // 2 - // the system detects '$count' is relevant - - $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. - -### 🚦 batch -- **optimize multiple writes into one fat update** - ```ts - // call downstream effects only once - batch(() => { - $count(4) - $count(5) - $count(6) - }) - ``` - -### 🚦 types -- **`Signal`** — it's a signal fn -- **`Derived`** — it's a derived fn -- **`Valuable`** — could be `Signal` or `Derived` - - - -

- - - -## 🍋 strata prism -> *persistent app-level state* - -- single-source-of-truth state tree -- no spooky-dookie proxy magic — just god's honest javascript -- immutable except for `mutate(fn)` calls -- use many lenses, efficient reactivity -- chrono provides undo/redo history -- persistence, localstorage, cross-tab sync - -### 🔮 prism and lenses -- **import prism** - ```ts - import {Prism} from "@e280/strata" - ``` -- **prism is a state tree** - ```ts - const prism = new Prism({ - snacks: { - peanuts: 8, - bag: ["popcorn", "butter"], - person: { - name: "chase", - incredi: true, - }, - }, - }) - ``` -- **create lenses, which are views into state subtrees** - ```ts - const snacks = prism.lens(state => state.snacks) - const person = snacks.lens(state => state.person) - ``` - - you can lens another lens -- **`lens.state` is a cloned mutable snapshot with chill typings** - ```ts - snacks.state.peanuts // 8 - person.state.name // "chase" - - 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++ - // ⛔ attempted frozen mutation: throw errors - ``` -- **only formal mutations can actually change state** - ```ts - snacks.mutate(state => state.peanuts++) - // ✅ formal mutations to change state - - snacks.state.peanuts // 9 - ``` -- **array mutations are unironically based, actually** - ```ts - snacks.mutate(state => state.bag.push("salt")) - ``` - -### 🔮 chrono for time travel -- **import stuff** - ```ts - import {Chrono, chronicle} from "@e280/strata" - ``` -- **create a chronicle in your state** - ```ts - const prism = new Prism({ - - // chronicle stores history - // 👇 - snacks: chronicle({ - peanuts: 8, - bag: ["popcorn", "butter"], - person: { - name: "chase", - incredi: true, - }, - }), - }) - ``` - - *big-brain moment:* the whole chronicle *itself* is stored in the state.. serializable.. think persistence — user can close their project, reopen, and their undo/redo history is still chillin' — *brat girl summer* -- **create a chrono-wrapped lens to interact with your chronicle** - ```ts - const snacks = new Chrono(64, prism.lens(state => state.snacks)) - // 👆 - // how many past snapshots to store - ``` -- **mutations will advance history,** and undo/redo works - ```ts - snacks.mutate(s => s.peanuts = 101) - - snacks.undo() - // back to 8 peanuts - - snacks.redo() - // forward to 101 peanuts - ``` -- **check how many undoable or redoable steps are available** - ```ts - snacks.undoable // 1 - snacks.redoable // 0 - ``` -- **you can make sub-lenses of a chrono,** all their mutations advance history too -- **plz pinky-swear right now,** that you won't create a chrono under a lens under another chrono 💀 - -### 🔮 persistence to localStorage -- **import prism** - ```ts - import {Vault, LocalStore} from "@e280/strata" - ``` -- **create a local storage store** - ```ts - const store = new LocalStore("myAppState") - ``` -- **make a vault for your prism** - ```ts - const vault = new Vault({ - prism, - store, - version: 1, // 👈 bump this when you break your state schema! - }) - ``` - - `store` type is compatible with [`@e280/kv`](https://github.com/e280/kv) -- **cross-tab sync (load on storage events)** - ```ts - store.onStorageEvent(vault.load) - ``` -- **initial load** - ```ts - await vault.load() - ``` - - - -

- - - -## 🍋 strata wait -> *tiny async state helpers* - -***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. - -### ⌛ good things come to those who wait -- **import stuff** - ```ts - import {ok, err, nap} from "@e280/stz" - import {wait, waitFormal} from "@e280/strata" - ``` -- **wrap any async operation in a fancy wait** - ```ts - // wrap any async operation in a fancy wait - const $wait = wait(async() => { - await nap(100) - if (Math.random() > 0.5) return 123 - else throw new Error("bad luck!") - }) - ``` - - btw you can pass a promise instead of an async fn -- **check if it's done** - ```ts - console.log($wait().done) - // false -- sorry bro, its not ready yet - ``` -- **okay, we can actually await for the result** - ```ts - const result = await $wait.result - - if (result.ok) - console.log(result.value) - // 123 - else - console.error(result.error) - // Error: bad luck! - ``` - -### ⌛ waitFormal is persnickety belt-and-suspenders mode -- **you can get super explicit about the types** - ```ts - const $wait = waitFormal(async() => { - if (Math.random() > 0.5) - return ok(123) - - if (Math.random() < 0.01) - return err("unlucky") - - else - return err("bad roll") - }) - ``` - -### ⌛ wait, there's more -- maker - ```ts - makeWait() // pending - makeWait(ok(123)) - makeWait(err("uh oh")) - ``` -- status checkers - ```ts - isWaitPending($wait()) - isWaitDone($wait()) // ok or err - isWaitOk($wait()) - isWaitErr($wait()) - ``` -- value grabbers - ```ts - waitGetOk($wait()) // 123 | undefined - waitNeedOk($wait()) // 123 (or throws an error) - ``` - ```ts - waitGetErr($wait()) // "bad roll" | undefined - waitNeedErr($wait()) // "bad roll" (or throws an error) - ``` -- quick selector - ```ts - const text = waitSelect($wait(), { - pending: () => "still loading...", - ok: value => `ready: ${value}`, - err: error => `ack! ${error}`, - }) - ``` - - - -

- - - -## 🍋 strata tracker -> *reactivity integration hub* - -```ts -import {tracker} from "@e280/strata/tracker" -``` - -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. - -### 🪄 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) {} - - 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) - - effect(() => console.log($count.get())) - // 1 - - $count.set(2) - // 2 - ``` - -### 🪄 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) - - - -

- - - -## 🍋 react bindings - -```ts -``` - -### ⚛️ setup your `strata.ts` module -```ts -import * as react from "react" -import {reactBindings} from "@e280/strata" - -export const { - component, - useTracked, - useOnce, - useSignal, - useDerived, -} = 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($count() + 1) - return -}) -``` - -### ⚛️ `useTracked` for a manual hands-on approach (plays nicer with hmr) -```ts -import {signal} from "@e280/strata" -import {useTracked} from "./strata.js" - -const $count = signal(0) - -export const MyCounter = () => { - const count = useTracked(() => $count()) - const add = () => $count($count() + 1) - return -} -``` - -### ⚛️ `useSignal` for local component state (and `useDerived`) -```ts -import {useSignal} from "./strata.js" - -export const MyCounter = () => { - const $count = useSignal(0) - const add = () => $count($count() + 1) - return -} -``` - - - -

- -## 🧑‍💻 strata is by e280 -free and open source by https://e280.org/ -join us if you're cool and good at dev - diff --git a/s/nova/index.ts b/s/nova/index.ts deleted file mode 100644 index cd17b03..0000000 --- a/s/nova/index.ts +++ /dev/null @@ -1,5 +0,0 @@ - -export * from "./bindings/index.js" -export * from "./core/index.js" -export * from "./tracker/index.js" - diff --git a/s/nova/prism/chrono/chronicle.ts b/s/nova/prism/chrono/chronicle.ts deleted file mode 100644 index 2926e1f..0000000 --- a/s/nova/prism/chrono/chronicle.ts +++ /dev/null @@ -1,11 +0,0 @@ - -import {Chronicle} from "./types.js" - -export function chronicle(state: State): Chronicle { - return { - past: [], - present: state, - future: [], - } -} - diff --git a/s/nova/prism/chrono/chrono.ts b/s/nova/prism/chrono/chrono.ts deleted file mode 100644 index 449c2ab..0000000 --- a/s/nova/prism/chrono/chrono.ts +++ /dev/null @@ -1,95 +0,0 @@ - -import {deep} from "@e280/stz" -import {Lens} from "../lens.js" -import {Chronicle} from "./types.js" -import {LensLike} from "../types.js" -import {_optic} from "../utils/optic-symbol.js" - -export class Chrono implements LensLike { - constructor( - public limit: number, - private basis: Lens>, - ) {} - - get chronicle() { - return this.basis.frozen - } - - get state() { - return this.basis.state.present - } - - get frozen() { - return this.basis.frozen.present - } - - get undoable() { - return this.chronicle.past.length - } - - get redoable() { - return this.chronicle.future.length - } - - #mut(chronicle: Chronicle, fn: (state: State) => R) { - const limit = Math.max(0, this.limit) - const snapshot = deep.clone(this.chronicle.present) as State - const result = fn(chronicle.present) - chronicle.past.push(snapshot) - chronicle.past = chronicle.past.slice(-limit) - chronicle.future = [] - return result - } - - /** progress forwards, depositing history into the past */ - mutate(fn: (state: State) => R): R { - return this.basis.mutate(chronicle => this.#mut(chronicle, fn)) - } - - /** step backwards into the past, by n steps */ - undo(n = 1) { - this.basis.mutate(chronicle => { - const snapshots = chronicle.past.slice(-n) - if (snapshots.length >= n) { - const oldPresent = chronicle.present - chronicle.present = snapshots.shift()! - chronicle.past = chronicle.past.slice(0, -n) - chronicle.future.unshift(oldPresent, ...snapshots) - } - }) - } - - /** step forwards into the future, by n steps */ - redo(n = 1) { - this.basis.mutate(chronicle => { - const snapshots = chronicle.future.slice(0, n) - if (snapshots.length >= n) { - const oldPresent = chronicle.present - chronicle.present = snapshots.shift()! - chronicle.past.push(oldPresent, ...snapshots) - chronicle.future = chronicle.future.slice(n) - } - }) - } - - /** wipe past and future snapshots */ - wipe() { - this.basis.mutate(chronicle => { - chronicle.past = [] - chronicle.future = [] - }) - } - - lens(selector: (state: State) => State2) { - const lens = new Lens({ - registerLens: this.basis[_optic].registerLens, - getState: () => selector(this.basis[_optic].getState().present), - mutate: fn => this.basis[_optic].mutate(chronicle => { - return this.#mut(chronicle, state => fn(selector(state))) - }), - }) - this.basis[_optic].registerLens(lens) - return lens - } -} - diff --git a/s/nova/prism/chrono/types.ts b/s/nova/prism/chrono/types.ts deleted file mode 100644 index d5e4e41..0000000 --- a/s/nova/prism/chrono/types.ts +++ /dev/null @@ -1,12 +0,0 @@ - -export type Chronicle = { - // [abc] d [efg] - // \ \ \ - // \ \ future - // \ present - // past - past: State[] - present: State - future: State[] -} - diff --git a/s/nova/prism/index.ts b/s/nova/prism/index.ts deleted file mode 100644 index dcbe3d9..0000000 --- a/s/nova/prism/index.ts +++ /dev/null @@ -1,11 +0,0 @@ - -export * from "./chrono/chronicle.js" -export * from "./chrono/chrono.js" -export * from "./chrono/types.js" -export * from "./vault/local-store.js" -export * from "./vault/vault.js" -export * from "./vault/types.js" -export * from "./lens.js" -export * from "./prism.js" -export * from "./types.js" - diff --git a/s/nova/prism/lens.ts b/s/nova/prism/lens.ts deleted file mode 100644 index 55334c8..0000000 --- a/s/nova/prism/lens.ts +++ /dev/null @@ -1,60 +0,0 @@ - -import {deep} from "@e280/stz" -import {immute} from "./utils/immute.js" -import {tracker} from "../tracker/tracker.js" -import {_optic} from "./utils/optic-symbol.js" -import {CacheCell} from "./utils/cache-cell.js" -import {Immutable, LensLike, Optic} from "./types.js" - -/** reactive view into a state prism, with formalized mutations */ -export class Lens implements LensLike { - ;[_optic]: Optic - #previous: State - #stateCache: CacheCell - #frozenCache: CacheCell> - - constructor(optic: Optic) { - this[_optic] = optic - this.#previous = deep.clone(optic.getState()) - this.#stateCache = new CacheCell(() => deep.clone(optic.getState())) - this.#frozenCache = new CacheCell(() => immute(optic.getState())) - } - - 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) - tracker.write(this) - } - } - - /** get a snapshot of the current state. it's typed as mutable, but you should not mutate it. */ - get state() { - tracker.read(this) - return this.#stateCache.get() - } - - /** get an immutable readonly snapshot of the current state. */ - get frozen() { - tracker.read(this) - return this.#frozenCache.get() - } - - mutate(fn: (state: State) => R) { - return this[_optic].mutate(fn) - } - - lens(selector: (state: State) => State2) { - const lens = new Lens({ - getState: () => selector(this[_optic].getState()), - mutate: fn => this[_optic].mutate(state => fn(selector(state))), - registerLens: this[_optic].registerLens, - }) - this[_optic].registerLens(lens) - return lens - } -} - diff --git a/s/nova/prism/prism.ts b/s/nova/prism/prism.ts deleted file mode 100644 index 1f603c0..0000000 --- a/s/nova/prism/prism.ts +++ /dev/null @@ -1,39 +0,0 @@ - -import {Lens} from "./lens.js" -import {tracker} from "../tracker/tracker.js" - -/** state mangagement source-of-truth */ -export class Prism { - #state: State - #lenses = new Set>() - - constructor(state: State) { - this.#state = state - } - - get() { - tracker.read(this) - return this.#state - } - - set(state: State) { - this.#state = state - 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: fn => { - const result = fn(selector(this.#state)) - this.set(this.#state) - return result - }, - registerLens: lens => this.#lenses.add(lens), - }) - this.#lenses.add(lens) - return lens - } -} - diff --git a/s/nova/prism/test.ts b/s/nova/prism/test.ts deleted file mode 100644 index 6be32c3..0000000 --- a/s/nova/prism/test.ts +++ /dev/null @@ -1,337 +0,0 @@ - -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 { 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) - 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 - effect(() => { - void prism.get().count - triggered++ - }) - expect(triggered).is(1) - prism.set({count: 2}) - expect(triggered).is(2) - }), - }), - - "lens": suite({ - "get state": test(async() => { - const prism = new Prism({data: {count: 1}}) - const lens = prism.lens(s => s.data) - expect(lens.frozen.count).is(1) - }), - - "state is immutable": test(async() => { - const prism = new Prism({data: {count: 1}}) - const lens = prism.lens(s => s.data) - expect(() => (lens.frozen as any).count++).throws() - }), - - "proper mutation": test(async() => { - const prism = new Prism({data: {count: 1}}) - const lens = prism.lens(s => s.data) - lens.mutate(s => s.count++) - expect(lens.frozen.count).is(2) - 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) - lens.mutate(s => s.data = {count: 2}) - expect(lens.frozen.data.count).is(2) - expect(() => (lens.frozen.data as any).count++).throws() - }), - - "effect reacts": test(async() => { - const prism = new Prism({data: {count: 1}}) - const lens = prism.lens(s => s.data) - let happenings = 0 - effect(() => { - void lens.frozen.count - happenings++ - }) - lens.mutate(s => s.count++) - expect(happenings).is(2) - }), - - "effects can be batched": test(async() => { - const prism = new Prism({data: {count: 1}}) - const lens = prism.lens(s => s.data) - let happenings = 0 - 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 - effect(() => lens.state, () => happenings++) - lens.mutate(s => s.array.push("lmao")) - expect(happenings).is(1) - expect(lens.frozen.array.length).is(2) - }), - - "sync coherence": test(async() => { - const prism = new Prism({data: {count: 1}}) - const lens = prism.lens(s => s.data) - lens.mutate(s => s.count++) - expect(lens.frozen.count).is(2) - lens.mutate(s => s.count++) - expect(lens.frozen.count).is(3) - }), - - "nullable selector": test(async() => { - type S = {a?: {b: {count: number}}} - const prism = new Prism({a: {b: {count: 1}}}) - const lens = prism.lens(s => s.a?.b) - expect(lens.frozen?.count).is(1) - prism.set({a: undefined}) - expect(lens.frozen?.count).is(undefined) - }), - - "deep composition": test(async() => { - const prism = new Prism({a: {b: {count: 1}}}) - const lensA = prism.lens(s => s.a) - const lensB = lensA.lens(s => s.b) - expect(prism.get().a.b.count).is(1) - expect(lensA.frozen.b.count).is(1) - expect(lensB.frozen.count).is(1) - }), - - "deep mutations": test(async() => { - const prism = new Prism({a: {b: {count: 1}}}) - const lensA = prism.lens(s => s.a) - const lensB = lensA.lens(s => s.b) - 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) - 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) - }), - - "outside mutations ignored": test(async() => { - const prism = new Prism({a: {count: 1}, b: {count: 101}}) - const lensA = prism.lens(s => s.a) - const lensB = prism.lens(s => s.b) - let happeningsA = 0 - let happeningsB = 0 - effect(() => lensA.state, () => happeningsA++) - effect(() => lensB.state, () => happeningsB++) - lensA.mutate(s => s.count++) - expect(happeningsA).is(1) - expect(happeningsB).is(0) - }), - - "outside mutations ignored for effects": test(async() => { - const prism = new Prism({a: {count: 1}, b: {count: 101}}) - const lensA = prism.lens(s => s.a) - const lensB = prism.lens(s => s.b) - let happeningsA = 0 - let happeningsB = 0 - effect(() => { - void lensA.frozen.count - happeningsA++ - }) - effect(() => { - void lensB.frozen.count - happeningsB++ - }) - lensA.mutate(s => s.count++) - expect(happeningsA).is(2) - expect(happeningsB).is(1) - }), - }), - - "chrono": suite({ - "get present state": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - expect(chrono.frozen.count).is(1) - }), - - "mutation": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - chrono.mutate(s => s.count++) - expect(chrono.frozen.count).is(2) - chrono.mutate(s => s.count++) - expect(chrono.frozen.count).is(3) - }), - - "undoable/redoable": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - expect(chrono.undoable).is(0) - expect(chrono.redoable).is(0) - chrono.mutate(s => s.count++) - expect(chrono.undoable).is(1) - chrono.mutate(s => s.count++) - expect(chrono.undoable).is(2) - chrono.undo() - expect(chrono.undoable).is(1) - expect(chrono.redoable).is(1) - chrono.undo() - expect(chrono.undoable).is(0) - expect(chrono.redoable).is(2) - chrono.redo() - expect(chrono.undoable).is(1) - expect(chrono.redoable).is(1) - }), - - "undo": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - - chrono.mutate(s => s.count++) - expect(chrono.frozen.count).is(2) - - chrono.undo() - expect(chrono.frozen.count).is(1) - }), - - "sync undo": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - - chrono.mutate(s => s.count++) - expect(chrono.frozen.count).is(2) - - chrono.undo() - expect(chrono.frozen.count).is(1) - }), - - "redo": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - - chrono.mutate(s => s.count++) - expect(chrono.frozen.count).is(2) - - chrono.undo() - expect(chrono.frozen.count).is(1) - - chrono.redo() - expect(chrono.frozen.count).is(2) - }), - - "undo/redo is orderly": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - - chrono.mutate(s => s.count++) - chrono.mutate(s => s.count++) - chrono.mutate(s => s.count++) - expect(chrono.frozen.count).is(4) - - chrono.undo() - expect(chrono.frozen.count).is(3) - - chrono.undo() - expect(chrono.frozen.count).is(2) - - chrono.redo() - expect(chrono.frozen.count).is(3) - - chrono.redo() - expect(chrono.frozen.count).is(4) - - chrono.undo() - expect(chrono.frozen.count).is(3) - - chrono.undo() - expect(chrono.frozen.count).is(2) - - chrono.undo() - expect(chrono.frozen.count).is(1) - }), - - "undo nothing does nothing": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - chrono.undo() - expect(chrono.frozen.count).is(1) - }), - - "redo nothing does nothing": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - chrono.redo() - expect(chrono.frozen.count).is(1) - }), - - "undo 2x": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - chrono.mutate(s => s.count++) - chrono.mutate(s => s.count++) - chrono.mutate(s => s.count++) - expect(chrono.frozen.count).is(4) - chrono.undo(2) - expect(chrono.frozen.count).is(2) - }), - - "redo 2x": test(async() => { - const prism = new Prism({data: chronicle({count: 1})}) - const lens = prism.lens(s => s.data) - const chrono = new Chrono(64, lens) - chrono.mutate(s => s.count++) - chrono.mutate(s => s.count++) - chrono.mutate(s => s.count++) - expect(chrono.frozen.count).is(4) - chrono.undo(2) - expect(chrono.frozen.count).is(2) - chrono.redo(2) - expect(chrono.frozen.count).is(4) - }), - - "sublens mutations are undoable": test(async() => { - const prism = new Prism({data: chronicle({a: {count: 1}})}) - const chrono = new Chrono(64, prism.lens(s => s.data)) - const sublens = chrono.lens(s => s.a) - expect(sublens.frozen.count).is(1) - sublens.mutate(s => s.count++) - expect(sublens.frozen.count).is(2) - chrono.undo() - expect(sublens.frozen.count).is(1) - }), - }), -}) - diff --git a/s/nova/prism/types.ts b/s/nova/prism/types.ts deleted file mode 100644 index 0f00556..0000000 --- a/s/nova/prism/types.ts +++ /dev/null @@ -1,28 +0,0 @@ - -import {Lens} from "./lens.js" - -export type LensLike = { - readonly state: State - readonly frozen: Immutable - mutate(fn: (state: State) => R): R - lens(selector: (state: State) => S2): Lens -} - -export type Optic = { - getState: () => State - mutate: (fn: (state: State) => R) => R - registerLens: (lens: Lens) => void -} - -export type Immutable = - T extends (...args: any[]) => any ? T : - T extends readonly any[] ? ReadonlyArray> : - T extends object ? { readonly [K in keyof T]: Immutable } : - T - -export type Mutable = - T extends (...args: any[]) => any ? T : - T extends ReadonlyArray ? Mutable[] : - T extends object ? { -readonly [K in keyof T]: Mutable } : - T - diff --git a/s/nova/prism/utils/cache-cell.ts b/s/nova/prism/utils/cache-cell.ts deleted file mode 100644 index db06c86..0000000 --- a/s/nova/prism/utils/cache-cell.ts +++ /dev/null @@ -1,22 +0,0 @@ - -export class CacheCell { - #dirty = false - #value: R - - constructor(private calculate: () => R) { - this.#value = calculate() - } - - get() { - if (this.#dirty) { - this.#dirty = false - this.#value = this.calculate() - } - return this.#value - } - - invalidate() { - this.#dirty = true - } -} - diff --git a/s/nova/prism/utils/immute.ts b/s/nova/prism/utils/immute.ts deleted file mode 100644 index 4e11d5a..0000000 --- a/s/nova/prism/utils/immute.ts +++ /dev/null @@ -1,8 +0,0 @@ - -import {deep} from "@e280/stz" -import {Immutable} from "../types.js" - -export function immute(s: S) { - return deep.freeze(deep.clone(s)) as Immutable -} - diff --git a/s/nova/prism/utils/optic-symbol.ts b/s/nova/prism/utils/optic-symbol.ts deleted file mode 100644 index 3fd0437..0000000 --- a/s/nova/prism/utils/optic-symbol.ts +++ /dev/null @@ -1,3 +0,0 @@ - -export const _optic = Symbol("optic") - diff --git a/s/nova/prism/vault/local-store.ts b/s/nova/prism/vault/local-store.ts deleted file mode 100644 index 83238bb..0000000 --- a/s/nova/prism/vault/local-store.ts +++ /dev/null @@ -1,31 +0,0 @@ - -import {Cubby} from "./types.js" - -export class LocalStore implements Cubby { - constructor( - private key: string, - private storage: Storage = window.localStorage, - ) {} - - async get() { - const json = this.storage.getItem(this.key) - return json - ? JSON.parse(json) - : undefined - } - - async set(data: X) { - const json = JSON.stringify(data) - this.storage.setItem(this.key, json) - } - - onStorageEvent(fn: () => void) { - const listener = (event: StorageEvent) => { - if (event.storageArea === this.storage && event.key === this.key) - fn() - } - window.addEventListener("storage", listener) - return () => window.removeEventListener("storage", listener) - } -} - diff --git a/s/nova/prism/vault/types.ts b/s/nova/prism/vault/types.ts deleted file mode 100644 index 1de264a..0000000 --- a/s/nova/prism/vault/types.ts +++ /dev/null @@ -1,22 +0,0 @@ - -import {Prism} from "../prism.js" - -export type Versioned = { - state: State - version: number -} - -export type Cubby = { - get(): Promise - set(data: X | undefined): Promise -} - -/** @deprecated renamed to `Cubby` */ -export type EzStore = Cubby - -export type VaultOptions = { - version: number - prism: Prism - store: Cubby> -} - diff --git a/s/nova/prism/vault/vault.ts b/s/nova/prism/vault/vault.ts deleted file mode 100644 index c2b21c2..0000000 --- a/s/nova/prism/vault/vault.ts +++ /dev/null @@ -1,19 +0,0 @@ - -import {VaultOptions} from "./types.js" - -export class Vault { - constructor(private options: VaultOptions) {} - - load = async() => { - const {store, version, prism} = this.options - const pickle = await store.get() - if (pickle && pickle.version === version) - prism.set(pickle.state) - } - - save = async() => { - const {store, version, prism} = this.options - await store.set({version, state: prism.get()}) - } -} - diff --git a/s/nova/tracker/index.ts b/s/nova/tracker/index.ts deleted file mode 100644 index 3388e88..0000000 --- a/s/nova/tracker/index.ts +++ /dev/null @@ -1,3 +0,0 @@ - -export * from "./tracker.js" - diff --git a/s/nova/tracker/test.ts b/s/nova/tracker/test.ts deleted file mode 100644 index ad59c12..0000000 --- a/s/nova/tracker/test.ts +++ /dev/null @@ -1,145 +0,0 @@ - -import {Tracker} from "./tracker.js" -import {science, test, expect} from "@e280/science" - -export default science.suite({ - "subscriptions": test(async() => { - const tracker = new Tracker() - 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 = {} - - const {seen, value} = tracker.observe(() => { - tracker.read(item) - return 123 - }) - - expect(seen.has(item)).is(true) - expect(value).is(123) - }), - - "nested observe layers are isolated": test(async() => { - const tracker = new Tracker() - const outer = {} - const inner = {} - - const observed = tracker.observe(() => { - tracker.read(outer) - - const nested = tracker.observe(() => { - tracker.read(inner) - }) - - expect(nested.seen.has(inner)).is(true) - expect(nested.seen.has(outer)).is(false) - }) - - expect(observed.seen.has(outer)).is(true) - expect(observed.seen.has(inner)).is(false) - }), - - "batch dedupes subscriber calls": test(async() => { - const tracker = new Tracker() - let called = 0 - const item = {} - - tracker.subscribe(item, () => called++) - - tracker.batch(() => { - tracker.write(item) - tracker.write(item) - tracker.write(item) - }) - - 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/nova/tracker/tracker.ts b/s/nova/tracker/tracker.ts deleted file mode 100644 index b3debdc..0000000 --- a/s/nova/tracker/tracker.ts +++ /dev/null @@ -1,92 +0,0 @@ - -import {GWeakMap} from "@e280/stz" - -export type Trackable = object | symbol - -/** - * reactivity integration hub - */ -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) - } - - /** 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) - } - } - - batch = (fn: () => R) => { - this.#batchDepth++ - try { - return fn() - } - finally { - this.#batchDepth-- - if (this.#batchDepth === 0) - this.#flush() - } - } - - #run(fn: () => void) { - if (this.#busy.has(fn)) - throw new Error("circularity forbidden") - this.#busy.add(fn) - try { fn() } - finally { this.#busy.delete(fn) } - } - - #flush() { - while (this.#batchPending.size > 0) { - const pending = [...this.#batchPending] - this.#batchPending.clear() - - for (const fn of pending) - this.#run(fn) - } - } -} - -/** standard global tracker for integrations */ -export const tracker: Tracker = (globalThis as any)[Symbol.for("e280.tracker.2")] ??= new Tracker() - diff --git a/s/nova/wait/index.ts b/s/nova/wait/index.ts deleted file mode 100644 index 538790d..0000000 --- a/s/nova/wait/index.ts +++ /dev/null @@ -1,8 +0,0 @@ - -export * from "./parts/get.js" -export * from "./parts/is.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/nova/wait/parts/get.ts b/s/nova/wait/parts/get.ts deleted file mode 100644 index 10caf8a..0000000 --- a/s/nova/wait/parts/get.ts +++ /dev/null @@ -1,26 +0,0 @@ - -import {getErr, getOk, needErr, needOk} from "@e280/stz" -import {Wait} from "./type.js" - -export function waitGetOk(wait: Wait) { - return wait.done - ? getOk(wait) - : undefined -} - -export function waitNeedOk(wait: Wait) { - if (!wait.done) throw new Error("wait not done") - return needOk(wait) -} - -export function waitGetErr(wait: Wait) { - return wait.done - ? getErr(wait) - : undefined -} - -export function waitNeedErr(wait: Wait) { - if (!wait.done) throw new Error("wait not done") - return needErr(wait) -} - diff --git a/s/nova/wait/parts/is.ts b/s/nova/wait/parts/is.ts deleted file mode 100644 index 31710eb..0000000 --- a/s/nova/wait/parts/is.ts +++ /dev/null @@ -1,8 +0,0 @@ - -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 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/nova/wait/parts/select.ts b/s/nova/wait/parts/select.ts deleted file mode 100644 index 6e9fa5f..0000000 --- a/s/nova/wait/parts/select.ts +++ /dev/null @@ -1,27 +0,0 @@ - -import {Wait} from "./type.js" -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 - }) { - - const { - pending = () => {}, - ok = () => {}, - err = () => {}, - } = select - - if (isWaitPending(wait)) - return pending() - - else if (isWaitOk(wait)) - return ok(waitNeedOk(wait)) - - else - return err(waitNeedErr(wait)) -} - diff --git a/s/nova/wait/parts/type.ts b/s/nova/wait/parts/type.ts deleted file mode 100644 index b1855b7..0000000 --- a/s/nova/wait/parts/type.ts +++ /dev/null @@ -1,18 +0,0 @@ - -import {Err, Ok, Result} from "@e280/stz" -import {Derived} from "../../core/types.js" - -export type WaitPending = {done: false} -export type WaitResult = {done: true} & Result -export type WaitOk = {done: true} & Ok -export type WaitErr = {done: true} & Err - -export type Wait = - | WaitPending - | WaitResult - -export type WaitDerived = Derived> & { - ready: Promise - result: Promise> -} - diff --git a/s/nova/wait/parts/wait.ts b/s/nova/wait/parts/wait.ts deleted file mode 100644 index 89f1b3a..0000000 --- a/s/nova/wait/parts/wait.ts +++ /dev/null @@ -1,39 +0,0 @@ - -import {attemptAsync, getOk, Result} from "@e280/stz" - -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 waitFormal(attemptAsync(input)) -} - -export function waitFormal( - input: Promise> | (() => Promise>), - ) { - return waitFormalPromise( - (typeof input === "function") - ? input() - : input - ) -} - -function waitFormalPromise(promise: Promise>) { - const $wait = signal>(makeWait()) - const $derived = derived(() => $wait()) as WaitDerived - - $derived.result = promise.then(result => { - const r: WaitResult = {done: true, ...result} - $wait(r) - return r - }) - - $derived.ready = $derived.result.then(getOk) - - return $derived -} - diff --git a/s/nova/wait/test.ts b/s/nova/wait/test.ts deleted file mode 100644 index 264da5d..0000000 --- a/s/nova/wait/test.ts +++ /dev/null @@ -1,35 +0,0 @@ - -import {nap, needOk} from "@e280/stz" -import {expect, suite, test} from "@e280/science" - -import {wait} from "./parts/wait.js" -import {waitNeedErr, waitNeedOk} from "./parts/get.js" -import {isWaitDone, isWaitErr, isWaitPending} from "./parts/is.js" - -export default suite({ - "wait fn, done": test(async() => { - const $wait = wait(async() => { - await nap() - return 123 - }) - expect(isWaitPending($wait())).is(true) - expect(await $wait.ready).is(123) - expect(needOk(await $wait.result)).is(123) - expect(isWaitDone($wait())).is(true) - expect(waitNeedOk($wait())).is(123) - }), - - "wait fn, failed": test(async() => { - const $wait = wait(async() => { - await nap() - if (!!true) throw new Error("uh oh") - return 123 - }) - expect(isWaitPending($wait())).is(true) - expect(await $wait.ready).is(undefined) - expect((await $wait.result).ok).is(false) - expect(isWaitErr($wait())).is(true) - expect(waitNeedErr($wait()).message).is("uh oh") - }), -}) - 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/r/map.ts b/s/signals/r/map.ts deleted file mode 100644 index 6e11e01..0000000 --- a/s/signals/r/map.ts +++ /dev/null @@ -1,63 +0,0 @@ - -import {GMap} from "@e280/stz" -import {tracker} from "../../tracker/tracker.js" - -export class RMap extends GMap { - get size() { - tracker.notifyRead(this) - return super.size - } - - ;[Symbol.iterator]() { - tracker.notifyRead(this) - return super[Symbol.iterator]() - } - - keys() { - tracker.notifyRead(this) - return super.keys() - } - - values() { - tracker.notifyRead(this) - return super.values() - } - - entries() { - tracker.notifyRead(this) - return super.entries() - } - - forEach(callbackFn: (value: V, key: K, map: Map) => void) { - tracker.notifyRead(this) - return super.forEach(callbackFn) - } - - has(key: K) { - tracker.notifyRead(this) - return super.has(key) - } - - get(key: K) { - tracker.notifyRead(this) - return super.get(key) - } - - set(key: K, value: V) { - const r = super.set(key, value) - tracker.notifyWrite(this) - return r - } - - delete(key: K) { - const r = super.delete(key) - tracker.notifyWrite(this) - return r - } - - clear() { - super.clear() - tracker.notifyWrite(this) - } -} - diff --git a/s/signals/r/set.ts b/s/signals/r/set.ts deleted file mode 100644 index 64793a2..0000000 --- a/s/signals/r/set.ts +++ /dev/null @@ -1,44 +0,0 @@ - -import {GSet} from "@e280/stz" -import {tracker} from "../../tracker/tracker.js" - -export class RSet extends GSet { - get size() { - tracker.notifyRead(this) - return super.size - } - - ;[Symbol.iterator]() { - tracker.notifyRead(this) - return super[Symbol.iterator]() - } - - values() { - tracker.notifyRead(this) - return super.values() - } - - has(item: T) { - tracker.notifyRead(this) - return super.has(item) - } - - add(item: T) { - super.add(item) - tracker.notifyWrite(this) - return this - } - - delete(item: T) { - const r = super.delete(item) - tracker.notifyWrite(this) - return r - } - - clear() { - super.clear() - tracker.notifyWrite(this) - return this - } -} - 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 22a49c3..47cb228 100644 --- a/s/test.ts +++ b/s/test.ts @@ -1,9 +1,9 @@ import {science} from "@e280/science" -import core from "./nova/core/test.js" -import wait from "./nova/wait/test.js" -import prism from "./nova/prism/test.js" -import tracker from "./nova/tracker/test.js" +import core from "./core/test.js" +import wait from "./wait/test.js" +import prism from "./prism/test.js" +import tracker from "./tracker/test.js" await science.run({ tracker, diff --git a/s/tracker/bindings/react.ts b/s/tracker/bindings/react.ts deleted file mode 100644 index 0eb3f74..0000000 --- a/s/tracker/bindings/react.ts +++ /dev/null @@ -1,53 +0,0 @@ - -import {tracker} from "../tracker.js" -import {signal} from "../../signals/signal/fn.js" -import {derived} from "../../signals/derived/fn.js" -import {SignalOptions} from "../../signals/types.js" - -export function react(react: { - useEffect: (fn: () => void | (() => void), deps?: unknown[]) => void - useState: (x: X | (() => X)) => [ - value: X, - set: (value: X | ((x: X) => X)) => void - ] - }) { - - const useStrata = (fn: () => X) => { - const [, setTick] = react.useState(0) - const {seen, result} = tracker.observe(fn) - - react.useEffect(() => { - const rerender = async() => setTick(tick => tick + 1) - const stoppers = [...seen].map(item => tracker.subscribe(item, rerender)) - return () => stoppers.forEach(stop => stop()) - }) - - return result - } - - const component =

(render: (props: P) => R) => { - const c = (props: P) => useStrata(() => render(props)) - c.displayName = (render as any).displayName ?? render.name ?? "Component" - return c - } - - const useOnce = (fn: () => X) => { - const [value] = react.useState(fn) - return value - } - - const useSignal = (value: X, options?: Partial) => { - const $signal = useOnce(() => signal(value, options)) - void useStrata(() => $signal()) - return $signal - } - - const useDerived = (formula: () => X, options?: Partial) => { - const $derived = useOnce(() => derived(formula, options)) - void useStrata(() => $derived()) - return $derived - } - - return {component, useStrata, useOnce, useSignal, useDerived} -} - diff --git a/s/tracker/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/nova/wait/parts/make.ts b/s/wait/parts/make.ts similarity index 100% rename from s/nova/wait/parts/make.ts rename to s/wait/parts/make.ts diff --git a/s/wait/parts/new.ts b/s/wait/parts/new.ts deleted file mode 100644 index b4d4231..0000000 --- a/s/wait/parts/new.ts +++ /dev/null @@ -1,10 +0,0 @@ - -import {Result} from "@e280/stz" -import {Wait} from "./type.js" - -export function newWait(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 }) From c43ba6598d8c28e0d71265907b8f7bd0de50ced0 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 23:48:09 -0700 Subject: [PATCH 18/48] set: npm tag to 'next' --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 177ecda9d66e8688c97af6b65467811ccb83d57d Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 23:55:27 -0700 Subject: [PATCH 19/48] fix: exports --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 5a4b492..aca8a94 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,9 @@ ], "exports": { ".": "./x/index.js", + "./bindings": "./x/bindings/index.js", + "./core": "./x/core/index.js", "./prism": "./x/prism/index.js", - "./signals": "./x/signals/index.js", "./tracker": "./x/tracker/index.js", "./wait": "./x/wait/index.js" }, From 060cd8d56e389ed97288b55431031909631580e3 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Sun, 3 May 2026 23:57:05 -0700 Subject: [PATCH 20/48] rename: 'core' to 'signals' --- package.json | 2 +- s/bindings/react.ts | 4 ++-- s/index.ts | 2 +- s/prism/test.ts | 4 ++-- s/{core => signals}/batch.ts | 0 s/{core => signals}/derived.ts | 0 s/{core => signals}/effect.ts | 0 s/{core => signals}/index.ts | 0 s/{core => signals}/r/map.ts | 0 s/{core => signals}/r/set.ts | 0 s/{core => signals}/signal.ts | 0 s/{core => signals}/test.ts | 0 s/{core => signals}/types.ts | 0 s/{core => signals}/utils/watch.ts | 0 s/test.ts | 8 ++++---- s/wait/parts/type.ts | 2 +- s/wait/parts/wait.ts | 4 ++-- 17 files changed, 13 insertions(+), 13 deletions(-) rename s/{core => signals}/batch.ts (100%) rename s/{core => signals}/derived.ts (100%) rename s/{core => signals}/effect.ts (100%) rename s/{core => signals}/index.ts (100%) rename s/{core => signals}/r/map.ts (100%) rename s/{core => signals}/r/set.ts (100%) rename s/{core => signals}/signal.ts (100%) rename s/{core => signals}/test.ts (100%) rename s/{core => signals}/types.ts (100%) rename s/{core => signals}/utils/watch.ts (100%) diff --git a/package.json b/package.json index aca8a94..0610b83 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "exports": { ".": "./x/index.js", "./bindings": "./x/bindings/index.js", - "./core": "./x/core/index.js", "./prism": "./x/prism/index.js", + "./signals": "./x/signals/index.js", "./tracker": "./x/tracker/index.js", "./wait": "./x/wait/index.js" }, diff --git a/s/bindings/react.ts b/s/bindings/react.ts index b794242..f85c271 100644 --- a/s/bindings/react.ts +++ b/s/bindings/react.ts @@ -1,6 +1,6 @@ -import {signal} from "../core/signal.js" -import {derived} from "../core/derived.js" +import {signal} from "../signals/signal.js" +import {derived} from "../signals/derived.js" import {tracker} from "../tracker/tracker.js" export function reactBindings(react: { diff --git a/s/index.ts b/s/index.ts index 58a81b0..9c494d3 100644 --- a/s/index.ts +++ b/s/index.ts @@ -1,6 +1,6 @@ export * from "./bindings/index.js" -export * from "./core/index.js" +export * from "./signals/index.js" export * from "./prism/index.js" export * from "./tracker/index.js" export * from "./wait/index.js" diff --git a/s/prism/test.ts b/s/prism/test.ts index 6be32c3..be4db88 100644 --- a/s/prism/test.ts +++ b/s/prism/test.ts @@ -2,10 +2,10 @@ import {suite, test, expect} from "@e280/science" import {Prism} from "./prism.js" -import {effect} from "../core/effect.js" +import {effect} from "../signals/effect.js" import {Chrono} from "./chrono/chrono.js" import {chronicle} from "./chrono/chronicle.js" -import { batch } from "../core/batch.js" +import { batch } from "../signals/batch.js" export default suite({ "prism": suite({ diff --git a/s/core/batch.ts b/s/signals/batch.ts similarity index 100% rename from s/core/batch.ts rename to s/signals/batch.ts diff --git a/s/core/derived.ts b/s/signals/derived.ts similarity index 100% rename from s/core/derived.ts rename to s/signals/derived.ts diff --git a/s/core/effect.ts b/s/signals/effect.ts similarity index 100% rename from s/core/effect.ts rename to s/signals/effect.ts diff --git a/s/core/index.ts b/s/signals/index.ts similarity index 100% rename from s/core/index.ts rename to s/signals/index.ts diff --git a/s/core/r/map.ts b/s/signals/r/map.ts similarity index 100% rename from s/core/r/map.ts rename to s/signals/r/map.ts diff --git a/s/core/r/set.ts b/s/signals/r/set.ts similarity index 100% rename from s/core/r/set.ts rename to s/signals/r/set.ts diff --git a/s/core/signal.ts b/s/signals/signal.ts similarity index 100% rename from s/core/signal.ts rename to s/signals/signal.ts diff --git a/s/core/test.ts b/s/signals/test.ts similarity index 100% rename from s/core/test.ts rename to s/signals/test.ts diff --git a/s/core/types.ts b/s/signals/types.ts similarity index 100% rename from s/core/types.ts rename to s/signals/types.ts diff --git a/s/core/utils/watch.ts b/s/signals/utils/watch.ts similarity index 100% rename from s/core/utils/watch.ts rename to s/signals/utils/watch.ts diff --git a/s/test.ts b/s/test.ts index 47cb228..a19ffe4 100644 --- a/s/test.ts +++ b/s/test.ts @@ -1,14 +1,14 @@ 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, tracker, - core, wait, - prism, }) diff --git a/s/wait/parts/type.ts b/s/wait/parts/type.ts index b1855b7..2e8c954 100644 --- a/s/wait/parts/type.ts +++ b/s/wait/parts/type.ts @@ -1,6 +1,6 @@ import {Err, Ok, Result} from "@e280/stz" -import {Derived} from "../../core/types.js" +import {Derived} from "../../signals/types.js" export type WaitPending = {done: false} export type WaitResult = {done: true} & Result diff --git a/s/wait/parts/wait.ts b/s/wait/parts/wait.ts index 89f1b3a..dfe4e00 100644 --- a/s/wait/parts/wait.ts +++ b/s/wait/parts/wait.ts @@ -2,8 +2,8 @@ import {attemptAsync, getOk, Result} from "@e280/stz" import {makeWait} from "./make.js" -import {signal} from "../../core/signal.js" -import {derived} from "../../core/derived.js" +import {signal} from "../../signals/signal.js" +import {derived} from "../../signals/derived.js" import {Wait, WaitResult, WaitDerived} from "./type.js" export function wait( From 54e536c3b27e75c2b44ae98169e209d6ee65bce7 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Mon, 4 May 2026 00:01:03 -0700 Subject: [PATCH 21/48] 0.4.0-next.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2462eb0..e79de81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@e280/strata", - "version": "0.3.5", + "version": "0.4.0-next.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@e280/strata", - "version": "0.3.5", + "version": "0.4.0-next.0", "license": "MIT", "dependencies": { "@e280/stz": "^0.2.35" diff --git a/package.json b/package.json index 0610b83..516ea8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@e280/strata", - "version": "0.3.5", + "version": "0.4.0-next.0", "description": "state management", "license": "MIT", "author": "Chase Moskal ", From 08be21c5346b4ed09e690fa04c9ac37618899717 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Mon, 4 May 2026 00:11:10 -0700 Subject: [PATCH 22/48] add: global symbol note in changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e923b7a..92d1e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - 🟥 everything is now sync, not async anymore. stripped of all debouncing and async shenanigans, now calling `tracker.write` (and thus setting signals, updating prism state, etc) immediately executes all downstream subscribers without any delay. this greatly improves our ability to detect and prevent scary catastropic circular-loop crashes. this also avoids async fatigue spreading through your codebases. the downside is that this could lead to worse performance. - 🍏 new `batch` fn helps you optimize performance -- batched tracker writes are deduped and flushed at the end of the batch, meaning, effects are only called once. - 🟥 **tracker** + - 🟥 ⚠️⚠️ global symbol changed from `e280.tracker` to `e280.tracker.2` -- this means strata v0.3 and v0.4 are treated like totally different state management libraries, they are FULLY incompatible, eg, if you have one dependency on strata 0.3 and another on 0.4, the `effect` from one will be blind to signals from the other. - 🟥 renamed `tracker.notifyRead` to `tracker.read` - 🟥 renamed `tracker.notifyWrite` to `tracker.write` - 🟥 **signals/derived** From 6a4d913c02310b5cd35e539746afa67da3923d43 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Mon, 4 May 2026 00:13:35 -0700 Subject: [PATCH 23/48] move: tracker global into global.ts --- s/bindings/react.ts | 2 +- s/prism/lens.ts | 2 +- s/prism/prism.ts | 2 +- s/signals/batch.ts | 2 +- s/signals/derived.ts | 2 +- s/signals/r/map.ts | 2 +- s/signals/r/set.ts | 2 +- s/signals/signal.ts | 2 +- s/signals/test.ts | 2 +- s/signals/utils/watch.ts | 2 +- s/tracker/global.ts | 6 ++++++ s/tracker/index.ts | 1 + s/tracker/tracker.ts | 3 --- 13 files changed, 17 insertions(+), 13 deletions(-) create mode 100644 s/tracker/global.ts diff --git a/s/bindings/react.ts b/s/bindings/react.ts index f85c271..8b77e58 100644 --- a/s/bindings/react.ts +++ b/s/bindings/react.ts @@ -1,7 +1,7 @@ import {signal} from "../signals/signal.js" +import {tracker} from "../tracker/global.js" import {derived} from "../signals/derived.js" -import {tracker} from "../tracker/tracker.js" export function reactBindings(react: { useEffect: (fn: () => void | (() => void), deps?: unknown[]) => void diff --git a/s/prism/lens.ts b/s/prism/lens.ts index 55334c8..98725c5 100644 --- a/s/prism/lens.ts +++ b/s/prism/lens.ts @@ -1,7 +1,7 @@ import {deep} from "@e280/stz" import {immute} from "./utils/immute.js" -import {tracker} from "../tracker/tracker.js" +import {tracker} from "../tracker/global.js" import {_optic} from "./utils/optic-symbol.js" import {CacheCell} from "./utils/cache-cell.js" import {Immutable, LensLike, Optic} from "./types.js" diff --git a/s/prism/prism.ts b/s/prism/prism.ts index 1f603c0..a00b614 100644 --- a/s/prism/prism.ts +++ b/s/prism/prism.ts @@ -1,6 +1,6 @@ import {Lens} from "./lens.js" -import {tracker} from "../tracker/tracker.js" +import {tracker} from "../tracker/global.js" /** state mangagement source-of-truth */ export class Prism { diff --git a/s/signals/batch.ts b/s/signals/batch.ts index dc399dc..4cfdd8f 100644 --- a/s/signals/batch.ts +++ b/s/signals/batch.ts @@ -1,5 +1,5 @@ -import {tracker} from "../tracker/tracker.js" +import {tracker} from "../tracker/global.js" export const batch = tracker.batch diff --git a/s/signals/derived.ts b/s/signals/derived.ts index 06e21dd..11d5aff 100644 --- a/s/signals/derived.ts +++ b/s/signals/derived.ts @@ -1,7 +1,7 @@ import {Derived} from "./types.js" import {watch} from "./utils/watch.js" -import {tracker} from "../tracker/tracker.js" +import {tracker} from "../tracker/global.js" export function derived(fn: () => Value): Derived { let value!: Value diff --git a/s/signals/r/map.ts b/s/signals/r/map.ts index d83af90..b183637 100644 --- a/s/signals/r/map.ts +++ b/s/signals/r/map.ts @@ -1,6 +1,6 @@ import {GMap} from "@e280/stz" -import {tracker} from "../../tracker/tracker.js" +import {tracker} from "../../tracker/global.js" export class RMap extends GMap { get size() { diff --git a/s/signals/r/set.ts b/s/signals/r/set.ts index 27e1b87..05ba9c9 100644 --- a/s/signals/r/set.ts +++ b/s/signals/r/set.ts @@ -1,6 +1,6 @@ import {GSet} from "@e280/stz" -import {tracker} from "../../tracker/tracker.js" +import {tracker} from "../../tracker/global.js" export class RSet extends GSet { get size() { diff --git a/s/signals/signal.ts b/s/signals/signal.ts index f310d4b..3f99463 100644 --- a/s/signals/signal.ts +++ b/s/signals/signal.ts @@ -1,6 +1,6 @@ import {Signal} from "./types.js" -import {tracker} from "../tracker/tracker.js" +import {tracker} from "../tracker/global.js" export function signal(value: Value): Signal { return function sig() { diff --git a/s/signals/test.ts b/s/signals/test.ts index ad5042e..d075d99 100644 --- a/s/signals/test.ts +++ b/s/signals/test.ts @@ -4,7 +4,7 @@ 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" +import {tracker} from "../tracker/global.js" export default science.suite({ "effect": test(async() => { diff --git a/s/signals/utils/watch.ts b/s/signals/utils/watch.ts index d66b387..a5d7c12 100644 --- a/s/signals/utils/watch.ts +++ b/s/signals/utils/watch.ts @@ -1,5 +1,5 @@ -import {tracker} from "../../tracker/tracker.js" +import {tracker} from "../../tracker/global.js" export function watch(collector: () => Value, onChange: () => void) { const {seen, value} = tracker.observe(collector) diff --git a/s/tracker/global.ts b/s/tracker/global.ts new file mode 100644 index 0000000..04a4f0d --- /dev/null +++ b/s/tracker/global.ts @@ -0,0 +1,6 @@ + +import {Tracker} from "./tracker.js" + +/** standard global tracker for integrations */ +export const tracker: Tracker = (globalThis as any)[Symbol.for("e280.tracker.2")] ??= new Tracker() + diff --git a/s/tracker/index.ts b/s/tracker/index.ts index 3388e88..ffea678 100644 --- a/s/tracker/index.ts +++ b/s/tracker/index.ts @@ -1,3 +1,4 @@ +export * from "./global.js" export * from "./tracker.js" diff --git a/s/tracker/tracker.ts b/s/tracker/tracker.ts index b3debdc..74112b7 100644 --- a/s/tracker/tracker.ts +++ b/s/tracker/tracker.ts @@ -87,6 +87,3 @@ export class Tracker { } } -/** standard global tracker for integrations */ -export const tracker: Tracker = (globalThis as any)[Symbol.for("e280.tracker.2")] ??= new Tracker() - From bcce11cbf71bdb40f82d34496e0877d52a2ea207 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Mon, 4 May 2026 00:56:43 -0700 Subject: [PATCH 24/48] 0.4.0-next.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e79de81..a087a0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@e280/strata", - "version": "0.4.0-next.0", + "version": "0.4.0-next.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@e280/strata", - "version": "0.4.0-next.0", + "version": "0.4.0-next.1", "license": "MIT", "dependencies": { "@e280/stz": "^0.2.35" diff --git a/package.json b/package.json index 516ea8a..cc8c73c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@e280/strata", - "version": "0.4.0-next.0", + "version": "0.4.0-next.1", "description": "state management", "license": "MIT", "author": "Chase Moskal ", From ec94c8d0e1ff764f9c3e9e8b26bc48ed7ea3f640 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Mon, 4 May 2026 01:06:53 -0700 Subject: [PATCH 25/48] update: readme --- README.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1b9994c..c060606 100644 --- a/README.md +++ b/README.md @@ -246,9 +246,10 @@ import {signal, derived, effect, batch} from "@e280/strata" ## 🍋 strata wait > *tiny async state helpers* -***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 is small.*** +it's about states like *pending, ok, err.* +wait extends [stz's ok/err](https://github.com/e280/stz#ok) toolkit. +it's mostly for showing little loading spinners in your ui. ### ⌛ good things come to those who wait - **import stuff** @@ -386,9 +387,6 @@ this is the inner sanctum of strata. use the tracker to jack into the reactivity ## 🍋 react bindings -```ts -``` - ### ⚛️ setup your `strata.ts` module ```ts import * as react from "react" From 922e2c81dfa4c3c6ef94a85e45b6e739be7aff78 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Mon, 4 May 2026 01:08:11 -0700 Subject: [PATCH 26/48] update: readme again --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index c060606..41a8e92 100644 --- a/README.md +++ b/README.md @@ -246,10 +246,8 @@ import {signal, derived, effect, batch} from "@e280/strata" ## 🍋 strata wait > *tiny async state helpers* -***wait is small.*** it's about states like *pending, ok, err.* -wait extends [stz's ok/err](https://github.com/e280/stz#ok) toolkit. -it's mostly for showing little loading spinners in your ui. +wait extends [stz's ok/err](https://github.com/e280/stz#ok) toolkit, and it's mostly for showing little loading spinners in your ui. ### ⌛ good things come to those who wait - **import stuff** From 9b93a06c71ad4aa94893c729a536e7d480613b98 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Mon, 4 May 2026 01:10:57 -0700 Subject: [PATCH 27/48] 0.4.0-next.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a087a0a..6dead7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@e280/strata", - "version": "0.4.0-next.1", + "version": "0.4.0-next.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@e280/strata", - "version": "0.4.0-next.1", + "version": "0.4.0-next.2", "license": "MIT", "dependencies": { "@e280/stz": "^0.2.35" diff --git a/package.json b/package.json index cc8c73c..7f8f213 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@e280/strata", - "version": "0.4.0-next.1", + "version": "0.4.0-next.2", "description": "state management", "license": "MIT", "author": "Chase Moskal ", From 1d645567e0bdffc71d82099f9f539e83ba1bcdd7 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Mon, 4 May 2026 01:36:37 -0700 Subject: [PATCH 28/48] simplify: effect to take only one fn --- README.md | 5 ++- s/prism/test.ts | 21 ++++++++---- s/signals/effect.ts | 12 ++----- s/signals/test.ts | 82 ++++++++++++++++++++++++++++++++------------- s/tracker/test.ts | 12 +++++++ 5 files changed, 90 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 41a8e92..620a315 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ import {signal, derived, effect, batch} from "@e280/strata" also, they have a `.dispose()` fn if you need to stop them. ### 🚦 effects -- **effects run when the relevant signals change** +- **effects run immediately, then again when relevant signals change** ```ts effect(() => console.log($count())) // 2 @@ -77,8 +77,7 @@ import {signal, derived, effect, batch} from "@e280/strata" // 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. + effect returns a dispose fn if you need to stop it. ### 🚦 batch - **optimize multiple writes into one fat update** diff --git a/s/prism/test.ts b/s/prism/test.ts index be4db88..0e1f4d0 100644 --- a/s/prism/test.ts +++ b/s/prism/test.ts @@ -90,9 +90,12 @@ export default suite({ const prism = new Prism({data: {array: ["lol"]}}) const lens = prism.lens(s => s.data) let happenings = 0 - effect(() => lens.state, () => happenings++) + effect(() => { + void lens.state + happenings++ + }) lens.mutate(s => s.array.push("lmao")) - expect(happenings).is(1) + expect(happenings).is(2) expect(lens.frozen.array.length).is(2) }), @@ -143,11 +146,17 @@ export default suite({ const lensB = prism.lens(s => s.b) let happeningsA = 0 let happeningsB = 0 - effect(() => lensA.state, () => happeningsA++) - effect(() => lensB.state, () => happeningsB++) + effect(() => { + void lensA.state + happeningsA++ + }) + effect(() => { + void lensB.state + happeningsB++ + }) lensA.mutate(s => s.count++) - expect(happeningsA).is(1) - expect(happeningsB).is(0) + expect(happeningsA).is(2) + expect(happeningsB).is(1) }), "outside mutations ignored for effects": test(async() => { diff --git a/s/signals/effect.ts b/s/signals/effect.ts index 9afbd00..dcdcf49 100644 --- a/s/signals/effect.ts +++ b/s/signals/effect.ts @@ -1,11 +1,7 @@ import {watch} from "./utils/watch.js" -export function effect( - collector: () => Collected, - responder: (collected: Collected) => void = () => {}, - ) { - +export function effect(fn: () => void) { let unwatch = () => {} const dispose = () => { @@ -15,13 +11,11 @@ export function effect( const update = () => { dispose() - const watched = watch(collector, update) + const watched = watch(fn, update) unwatch = watched.dispose - responder(watched.value) } - const watched = watch(collector, update) - unwatch = watched.dispose + update() return dispose } diff --git a/s/signals/test.ts b/s/signals/test.ts index d075d99..7721730 100644 --- a/s/signals/test.ts +++ b/s/signals/test.ts @@ -10,15 +10,18 @@ export default science.suite({ "effect": test(async() => { const item = {} let calls = 0 - const stop = effect(() => tracker.read(item), () => calls++) - expect(calls).is(0) + const stop = effect(() => { + tracker.read(item) + calls++ + }) + expect(calls).is(1) tracker.write(item) - expect(calls).is(1) + expect(calls).is(2) stop() tracker.write(item) - expect(calls).is(1) + expect(calls).is(2) }), "signal read/write": test(async() => { @@ -31,10 +34,13 @@ export default science.suite({ "signal triggers effects": test(async() => { const $count = signal(1) let calls = 0 - effect(() => $count(), () => calls++) - expect(calls).is(0) - $count(2) + effect(() => { + $count() + calls++ + }) expect(calls).is(1) + $count(2) + expect(calls).is(2) }), "shorthand effect syntax": test(async() => { @@ -86,19 +92,23 @@ export default science.suite({ derivedCalls++ return $alpha() * $bravo() }) - effect(() => $derived(), () => effectCalls++) + effect(() => { + $derived() + effectCalls++ + }) expect(derivedCalls).is(1) - expect(effectCalls).is(0) + expect(effectCalls).is(1) $alpha(3) expect(derivedCalls).is(2) - expect(effectCalls).is(1) + expect(effectCalls).is(2) }), "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)) + effect(() => calls.push($alpha() * $bravo())) + calls = [] batch(() => { $alpha(3) $alpha(4) @@ -110,29 +120,53 @@ export default science.suite({ expect(calls[0]).is(55) }), - "direct circularity forbidden": test(async() => { - const $count = signal(1) - effect(() => $count(), count => $count(count + 1)) - expect(() => $count(2)).throws() + "evil circularity is no problem": test(async() => { + const $alpha = signal(1) + let count = 0 + effect(() => { + count++ + if (count < 10) + $alpha($alpha() + 1) + }) + expect(count).lt(5) }), - "indirect effect circular write is forbidden": test(async() => { + "sneaky evil circularity is no problem": test(async() => { const $alpha = signal(1) const $bravo = signal(1) - let calls = 0 - effect(() => $alpha(), a => { - calls++ - $bravo(a + 1) + let countAlpha = 0 + effect(() => { + countAlpha++ + if (countAlpha < 10) + $alpha($bravo() + 1) + }) + + let countBravo = 0 + effect(() => { + countBravo++ + if (countBravo < 10) + $bravo($alpha() + 1) }) - effect(() => $bravo(), b => { + $alpha(99) + $bravo(99) + expect(countAlpha).lt(5) + expect(countBravo).lt(5) + }), + + "effect writes are self-damped": test(async() => { + const $count = signal(1) + let calls = 0 + + effect(() => { calls++ - $alpha(b + 1) + if (calls < 10) + $count($count() + 1) }) - expect(() => $alpha(2)).throws() - expect(calls).is(2) + expect(calls).is(1) + expect($count()).is(2) }), }) diff --git a/s/tracker/test.ts b/s/tracker/test.ts index ad59c12..69c5355 100644 --- a/s/tracker/test.ts +++ b/s/tracker/test.ts @@ -117,6 +117,17 @@ export default science.suite({ expect(calls.join(",")).is("a,b") }), + "writing to a different item in a subscriber is ok": test(async() => { + const tracker = new Tracker() + const alpha = {} + const bravo = {} + + const fn = () => tracker.write(bravo) + tracker.subscribe(alpha, fn) + + expect(() => tracker.write(alpha)).not.throws() + }), + "circularity is forbidden": test(async() => { const tracker = new Tracker() const item = {} @@ -143,3 +154,4 @@ export default science.suite({ expect(calls).is(1) }), }) + From 74a675b375aeb715bb6713a687fcbdbfae839586 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Mon, 4 May 2026 02:32:04 -0700 Subject: [PATCH 29/48] 0.4.0-next.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6dead7d..3fb7254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@e280/strata", - "version": "0.4.0-next.2", + "version": "0.4.0-next.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@e280/strata", - "version": "0.4.0-next.2", + "version": "0.4.0-next.3", "license": "MIT", "dependencies": { "@e280/stz": "^0.2.35" diff --git a/package.json b/package.json index 7f8f213..fce7793 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@e280/strata", - "version": "0.4.0-next.2", + "version": "0.4.0-next.3", "description": "state management", "license": "MIT", "author": "Chase Moskal ", From c93f3524da1a19ad6a18c71a3dab04bd8b0079f1 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Mon, 4 May 2026 02:33:11 -0700 Subject: [PATCH 30/48] update: changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92d1e79..8839622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,13 +18,14 @@ - 🟥 ⚠️⚠️ global symbol changed from `e280.tracker` to `e280.tracker.2` -- this means strata v0.3 and v0.4 are treated like totally different state management libraries, they are FULLY incompatible, eg, if you have one dependency on strata 0.3 and another on 0.4, the `effect` from one will be blind to signals from the other. - 🟥 renamed `tracker.notifyRead` to `tracker.read` - 🟥 renamed `tracker.notifyWrite` to `tracker.write` -- 🟥 **signals/derived** +- 🟥 **signals/derived/effects** - 🟥 eliminated all the funky magic class+fn implementations for dead-simple minimal implementations... new signal module is 18 lines... - 🟥 removed `$count.on` direct subscriptions -- just use effects - 🟥 removed `$count.value` accessors -- just use hipster-fn syntax - 🟥 removed `$count.get()` and `$count.set(v)` methods -- just use hipster-fn syntax - 🟥 removed comparison logic, now all signal value setting always notifies the tracker, doesn't care if there was a real change - 🟥 removed `lazy` completely removed -- obsoleted by superior new derived implementation that is lazy + - 🟥 `effect` is now simpler and only takes one simple callback fn - 🟥 **wait** - 🟥 renamed `WaitDone` to `WaitResult` to better match ok/err/result - 🟥 renamed `newWait` to `makeWait` because i like it more From a3dd74ce9585763925796c877763b670bf1d108d Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Mon, 4 May 2026 20:12:49 -0700 Subject: [PATCH 31/48] update: deps --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3fb7254..d73c793 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.4.0-next.3", "license": "MIT", "dependencies": { - "@e280/stz": "^0.2.35" + "@e280/stz": "^0.2.36" }, "devDependencies": { "@e280/octo": "^0.1.1", @@ -72,9 +72,9 @@ } }, "node_modules/@e280/stz": { - "version": "0.2.35", - "resolved": "https://registry.npmjs.org/@e280/stz/-/stz-0.2.35.tgz", - "integrity": "sha512-7UoJkrd/FvN6j/F9xn90sZo+nz63CH3FYzxGd4VTmOFOL7RkApTHni/vuIUvO1IBP1iWrp06VAoHWMhkO54PQg==", + "version": "0.2.36", + "resolved": "https://registry.npmjs.org/@e280/stz/-/stz-0.2.36.tgz", + "integrity": "sha512-lA+CqHdWabjUEpxFzYfag8Pb76EM4RfRyK+PWqYL0gkrhHxCkAcjESWKioAwXHQiJB/cdiIlYWSLc6qcIL0WIA==", "license": "MIT" }, "node_modules/@types/node": { diff --git a/package.json b/package.json index fce7793..93c9a89 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "count": "find s -path '*/_archive' -prune -o -name '*.ts' -exec wc -l {} +" }, "dependencies": { - "@e280/stz": "^0.2.35" + "@e280/stz": "^0.2.36" }, "devDependencies": { "@e280/octo": "^0.1.1", From bacaf5fcd3bf8ffd72bb44b44bb7f3d147513684 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Mon, 4 May 2026 20:16:33 -0700 Subject: [PATCH 32/48] update: rmap setEntries batch track --- s/signals/r/map.ts | 22 ++++++++++++++++++---- s/signals/r/set.ts | 13 +++++++++++-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/s/signals/r/map.ts b/s/signals/r/map.ts index b183637..c4cecfc 100644 --- a/s/signals/r/map.ts +++ b/s/signals/r/map.ts @@ -1,8 +1,14 @@ import {GMap} from "@e280/stz" +import {batch} from "../batch.js" import {tracker} from "../../tracker/global.js" export class RMap extends GMap { + + // + // reading + // + get size() { tracker.read(this) return super.size @@ -43,16 +49,24 @@ export class RMap extends GMap { return super.get(key) } + // + // writing + // + set(key: K, value: V) { - const r = super.set(key, value) + super.set(key, value) tracker.write(this) - return r + return this } delete(key: K) { - const r = super.delete(key) + const ret = super.delete(key) tracker.write(this) - return r + return ret + } + + setEntries(entries: Iterable<[K, V]>) { + return batch(() => super.setEntries(entries)) } clear() { diff --git a/s/signals/r/set.ts b/s/signals/r/set.ts index 05ba9c9..7a02a68 100644 --- a/s/signals/r/set.ts +++ b/s/signals/r/set.ts @@ -3,6 +3,11 @@ import {GSet} from "@e280/stz" import {tracker} from "../../tracker/global.js" export class RSet extends GSet { + + // + // reading + // + get size() { tracker.read(this) return super.size @@ -23,6 +28,10 @@ export class RSet extends GSet { return super.has(item) } + // + // writing + // + add(item: T) { super.add(item) tracker.write(this) @@ -30,9 +39,9 @@ export class RSet extends GSet { } delete(item: T) { - const r = super.delete(item) + const ret = super.delete(item) tracker.write(this) - return r + return ret } clear() { From be472bb812553c9e3f9fde5c64b4dbd49ebcce92 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Mon, 4 May 2026 20:25:44 -0700 Subject: [PATCH 33/48] tweak: gmap/gset foreach methods --- s/signals/r/map.ts | 12 +++++++----- s/signals/r/set.ts | 15 +++++++++++---- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/s/signals/r/map.ts b/s/signals/r/map.ts index c4cecfc..f3cc9ad 100644 --- a/s/signals/r/map.ts +++ b/s/signals/r/map.ts @@ -19,6 +19,13 @@ export class RMap extends GMap { return super[Symbol.iterator]() } + forEach(callbackFn: (value: V, key: K, map: RMap) => void) { + tracker.read(this) + for (const [key, value] of this) + callbackFn(value, key, this) + return this + } + keys() { tracker.read(this) return super.keys() @@ -34,11 +41,6 @@ export class RMap extends GMap { return super.entries() } - forEach(callbackFn: (value: V, key: K, map: Map) => void) { - tracker.read(this) - return super.forEach(callbackFn) - } - has(key: K) { tracker.read(this) return super.has(key) diff --git a/s/signals/r/set.ts b/s/signals/r/set.ts index 7a02a68..02aac14 100644 --- a/s/signals/r/set.ts +++ b/s/signals/r/set.ts @@ -2,7 +2,7 @@ import {GSet} from "@e280/stz" import {tracker} from "../../tracker/global.js" -export class RSet extends GSet { +export class RSet extends GSet { // // reading @@ -18,12 +18,19 @@ export class RSet extends GSet { return super[Symbol.iterator]() } + forEach(callbackFn: (value: V, value2: V, set: RSet) => void) { + tracker.read(this) + for (const value of this) + callbackFn(value, value, this) + return this + } + values() { tracker.read(this) return super.values() } - has(item: T) { + has(item: V) { tracker.read(this) return super.has(item) } @@ -32,13 +39,13 @@ export class RSet extends GSet { // writing // - add(item: T) { + add(item: V) { super.add(item) tracker.write(this) return this } - delete(item: T) { + delete(item: V) { const ret = super.delete(item) tracker.write(this) return ret From f6166ea749ccec8a2bccbccc647959ba9d351b1c Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Mon, 4 May 2026 20:27:18 -0700 Subject: [PATCH 34/48] 0.4.0-next.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d73c793..d83f8af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@e280/strata", - "version": "0.4.0-next.3", + "version": "0.4.0-next.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@e280/strata", - "version": "0.4.0-next.3", + "version": "0.4.0-next.4", "license": "MIT", "dependencies": { "@e280/stz": "^0.2.36" diff --git a/package.json b/package.json index 93c9a89..af808b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@e280/strata", - "version": "0.4.0-next.3", + "version": "0.4.0-next.4", "description": "state management", "license": "MIT", "author": "Chase Moskal ", From ed4ceb71d97088842a5d1197e5bf62e3f5a5d4f7 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Tue, 5 May 2026 00:19:04 -0700 Subject: [PATCH 35/48] add: changelog react bindings changes --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8839622..219c221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,9 @@ - 🟥 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 +- 🟥 **react bindings** + - 🟥 renamed `react` fn to `reactBindings` + - 🟥 renamed `useStrata` hook to `useTracked` From 94b1ec503c98fb24c11bd9d1fd809b2186385259 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Tue, 5 May 2026 00:22:14 -0700 Subject: [PATCH 36/48] rename: WaitDerived to Waiter --- CHANGELOG.md | 2 +- README.md | 2 +- s/wait/parts/type.ts | 2 +- s/wait/parts/wait.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 219c221..f3b81e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ - 🟥 **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 `WaitSignal` to `Waiter` - 🟥 renamed `waitResult` to `waitFormal` because i said so - 🟥 **react bindings** - 🟥 renamed `react` fn to `reactBindings` diff --git a/README.md b/README.md index 620a315..46ec4f3 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ wait extends [stz's ok/err](https://github.com/e280/stz#ok) toolkit, and it's mo import {ok, err, nap} from "@e280/stz" import {wait, waitFormal} from "@e280/strata" ``` -- **wrap any async operation in a fancy wait** +- **wrap any async operation and get a "Waiter"** ```ts // wrap any async operation in a fancy wait const $wait = wait(async() => { diff --git a/s/wait/parts/type.ts b/s/wait/parts/type.ts index 2e8c954..5f5cff7 100644 --- a/s/wait/parts/type.ts +++ b/s/wait/parts/type.ts @@ -11,7 +11,7 @@ export type Wait = | WaitPending | WaitResult -export type WaitDerived = Derived> & { +export type Waiter = Derived> & { ready: Promise result: Promise> } diff --git a/s/wait/parts/wait.ts b/s/wait/parts/wait.ts index dfe4e00..3ac3f92 100644 --- a/s/wait/parts/wait.ts +++ b/s/wait/parts/wait.ts @@ -4,7 +4,7 @@ import {attemptAsync, getOk, Result} from "@e280/stz" import {makeWait} from "./make.js" import {signal} from "../../signals/signal.js" import {derived} from "../../signals/derived.js" -import {Wait, WaitResult, WaitDerived} from "./type.js" +import {Wait, WaitResult, Waiter} from "./type.js" export function wait( input: Promise | (() => Promise), @@ -24,7 +24,7 @@ export function waitFormal( function waitFormalPromise(promise: Promise>) { const $wait = signal>(makeWait()) - const $derived = derived(() => $wait()) as WaitDerived + const $derived = derived(() => $wait()) as Waiter $derived.result = promise.then(result => { const r: WaitResult = {done: true, ...result} From 285438e0920aa3ac453dea8af42f3483074de0fb Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Tue, 5 May 2026 00:22:53 -0700 Subject: [PATCH 37/48] 0.4.0-next.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d83f8af..dc7d605 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@e280/strata", - "version": "0.4.0-next.4", + "version": "0.4.0-next.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@e280/strata", - "version": "0.4.0-next.4", + "version": "0.4.0-next.5", "license": "MIT", "dependencies": { "@e280/stz": "^0.2.36" diff --git a/package.json b/package.json index af808b9..4796390 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@e280/strata", - "version": "0.4.0-next.4", + "version": "0.4.0-next.5", "description": "state management", "license": "MIT", "author": "Chase Moskal ", From 866812c2ce5a5e61078587c1a9e3ad3d0436c482 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Tue, 5 May 2026 14:31:10 -0700 Subject: [PATCH 38/48] add: afterEffect helper fn --- CHANGELOG.md | 1 + s/signals/helpers/after-effect.ts | 20 ++++++++++++++++++++ s/signals/index.ts | 1 + 3 files changed, 22 insertions(+) create mode 100644 s/signals/helpers/after-effect.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b81e3..48d9345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - 🟥 removed comparison logic, now all signal value setting always notifies the tracker, doesn't care if there was a real change - 🟥 removed `lazy` completely removed -- obsoleted by superior new derived implementation that is lazy - 🟥 `effect` is now simpler and only takes one simple callback fn + - 🍏 new `afterEffect` takes a collector fn and responder fn like the old `effect` did - 🟥 **wait** - 🟥 renamed `WaitDone` to `WaitResult` to better match ok/err/result - 🟥 renamed `newWait` to `makeWait` because i like it more diff --git a/s/signals/helpers/after-effect.ts b/s/signals/helpers/after-effect.ts new file mode 100644 index 0000000..de9e097 --- /dev/null +++ b/s/signals/helpers/after-effect.ts @@ -0,0 +1,20 @@ + +import {effect} from "../effect.js" + +export function afterEffect( + collector: () => C, + responder: (collected: C) => void, + ) { + + let initialized = false + + return effect(() => { + const collected = collector() + + if (initialized) + responder(collected) + + initialized = true + }) +} + diff --git a/s/signals/index.ts b/s/signals/index.ts index 1a13ccd..2dce6f8 100644 --- a/s/signals/index.ts +++ b/s/signals/index.ts @@ -1,4 +1,5 @@ +export * from "./helpers/after-effect.js" export * from "./r/map.js" export * from "./r/set.js" export * from "./batch.js" From f883c5ce0c26b7759dbeb654a4dea9e80ac574c6 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Thu, 7 May 2026 02:19:24 -0700 Subject: [PATCH 39/48] add: failing test proves derived bug --- s/signals/test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/s/signals/test.ts b/s/signals/test.ts index 7721730..d95299d 100644 --- a/s/signals/test.ts +++ b/s/signals/test.ts @@ -63,6 +63,17 @@ export default science.suite({ expect($derived()).is(30) }), + "derived more": test(async() => { + const $alpha = signal(2) + const $bravo = signal(10) + const $derived = derived(() => $alpha() * $bravo()) + expect($derived()).is(20) + $alpha(3) + expect($derived()).is(30) + $alpha(4) + expect($derived()).is(40) + }), + "derived is lazy": test(async() => { const $alpha = signal(2) const $bravo = signal(10) From f1d1418bb68ebc6833303bb4b7e68ea3ba76efb4 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Thu, 7 May 2026 02:37:44 -0700 Subject: [PATCH 40/48] fix: derived --- s/signals/derived.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/s/signals/derived.ts b/s/signals/derived.ts index 11d5aff..1950d15 100644 --- a/s/signals/derived.ts +++ b/s/signals/derived.ts @@ -22,9 +22,11 @@ export function derived(fn: () => Value): Derived { } const compute = () => { + unwatch() + unwatch = () => {} + const watched = watch(fn, invalidate) - unwatch() unwatch = watched.dispose value = watched.value From e396d4567dd5442c650d6a98ee0fd4bf16add1dd Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Thu, 7 May 2026 02:44:57 -0700 Subject: [PATCH 41/48] add: more derived tests and adjusted behaviors --- s/signals/derived.ts | 28 ++++++++++++----- s/signals/test.ts | 73 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/s/signals/derived.ts b/s/signals/derived.ts index 1950d15..51cbb5d 100644 --- a/s/signals/derived.ts +++ b/s/signals/derived.ts @@ -6,6 +6,8 @@ import {tracker} from "../tracker/global.js" export function derived(fn: () => Value): Derived { let value!: Value let dirty = true + let failed = false + let computing = false let unwatch = () => {} const dispose = () => { @@ -15,22 +17,34 @@ export function derived(fn: () => Value): Derived { } const invalidate = () => { - if (!dirty) { + if (!dirty || failed) { dirty = true tracker.write(d) } } const compute = () => { - unwatch() - unwatch = () => {} + if (computing) + throw new Error("derived circularity forbidden") - const watched = watch(fn, invalidate) + computing = true + try { + const watched = watch(fn, () => invalidate()) - unwatch = watched.dispose + unwatch() + unwatch = watched.dispose - value = watched.value - dirty = false + value = watched.value + dirty = false + failed = false + } + catch (error) { + failed = true + throw error + } + finally { + computing = false + } } function d() { diff --git a/s/signals/test.ts b/s/signals/test.ts index d95299d..e3895d1 100644 --- a/s/signals/test.ts +++ b/s/signals/test.ts @@ -114,6 +114,78 @@ export default science.suite({ expect(effectCalls).is(2) }), + "derived tracks dynamic dependencies": test(async() => { + const $useAlpha = signal(true) + const $alpha = signal(2) + const $bravo = signal(10) + let calls = 0 + + const $derived = derived(() => { + calls++ + return $useAlpha() + ? $alpha() + : $bravo() + }) + + expect($derived()).is(2) + expect(calls).is(1) + + $bravo(11) + expect($derived()).is(2) + expect(calls).is(1) + + $useAlpha(false) + expect($derived()).is(11) + expect(calls).is(2) + + $alpha(3) + expect($derived()).is(11) + expect(calls).is(2) + + $bravo(12) + expect($derived()).is(12) + expect(calls).is(3) + }), + + "derived remains subscribed after a thrown recompute": test(async() => { + const $throws = signal(false) + const $count = signal(1) + const values: (number | "err")[] = [] + + const $derived = derived(() => { + if ($throws()) + throw new Error("boom") + return $count() * 2 + }) + + effect(() => { + try { + values.push($derived()) + } + catch { + values.push("err") + } + }) + + expect(values.join(",")).is("2") + + $throws(true) + expect(values.join(",")).is("2,err") + + $throws(false) + expect(values.join(",")).is("2,err,2") + + $count(3) + expect(values.join(",")).is("2,err,2,6") + }), + + "derived circular reads throw": test(async() => { + let $derived!: () => number + $derived = derived(() => $derived() + 1) + + expect(() => $derived()).throws() + }), + "batching signal effects seems to work": test(async() => { const $alpha = signal(2) const $bravo = signal(10) @@ -180,4 +252,3 @@ export default science.suite({ expect($count()).is(2) }), }) - From 795ca39ec6263003372431f076fe7eb9b7132039 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Thu, 7 May 2026 02:46:13 -0700 Subject: [PATCH 42/48] 0.4.0-next.6 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index dc7d605..126ae0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@e280/strata", - "version": "0.4.0-next.5", + "version": "0.4.0-next.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@e280/strata", - "version": "0.4.0-next.5", + "version": "0.4.0-next.6", "license": "MIT", "dependencies": { "@e280/stz": "^0.2.36" diff --git a/package.json b/package.json index 4796390..3fb907b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@e280/strata", - "version": "0.4.0-next.5", + "version": "0.4.0-next.6", "description": "state management", "license": "MIT", "author": "Chase Moskal ", From 934a6a334d48d3be20a331cbd2306efbd6a9d5c6 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Thu, 7 May 2026 02:51:21 -0700 Subject: [PATCH 43/48] add: nested batching test --- s/signals/test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/s/signals/test.ts b/s/signals/test.ts index e3895d1..7308eb9 100644 --- a/s/signals/test.ts +++ b/s/signals/test.ts @@ -203,6 +203,25 @@ export default science.suite({ expect(calls[0]).is(55) }), + "nested batching is fine": test(async() => { + const $alpha = signal(2) + const $bravo = signal(10) + let calls: number[] = [] + effect(() => calls.push($alpha() * $bravo())) + calls = [] + batch(() => { + $alpha(3) + $alpha(4) + batch(() => { + $alpha(5) + $bravo(1) + }) + $bravo(11) + }) + expect(calls.length).is(1) + expect(calls[0]).is(55) + }), + "evil circularity is no problem": test(async() => { const $alpha = signal(1) let count = 0 From 4f8c3bd923721ec47a9e58e1fd8f7bfc806befa8 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Mon, 11 May 2026 11:35:34 -0700 Subject: [PATCH 44/48] add: changelog Signaly->Valuable --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48d9345..f2e2ee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - 🟥 removed comparison logic, now all signal value setting always notifies the tracker, doesn't care if there was a real change - 🟥 removed `lazy` completely removed -- obsoleted by superior new derived implementation that is lazy - 🟥 `effect` is now simpler and only takes one simple callback fn + - 🟥 renamed type `Signaly` / `SignalyFn` to `Valuable` - 🍏 new `afterEffect` takes a collector fn and responder fn like the old `effect` did - 🟥 **wait** - 🟥 renamed `WaitDone` to `WaitResult` to better match ok/err/result From e7aeca6d8e787d942e6900456febffa6fc8a7e0e Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Tue, 12 May 2026 15:50:14 -0700 Subject: [PATCH 45/48] update: to stz next --- package-lock.json | 34 ++++++++++++++++++++++++---------- package.json | 4 ++-- s/signals/r/map.ts | 8 +------- s/signals/r/set.ts | 3 +-- s/tracker/tracker.ts | 6 +++--- 5 files changed, 31 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 126ae0a..88973d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,12 @@ "version": "0.4.0-next.6", "license": "MIT", "dependencies": { - "@e280/stz": "^0.2.36" + "@e280/stz": "^0.3.0-next.0" }, "devDependencies": { "@e280/octo": "^0.1.1", "@e280/science": "^0.1.11", - "@types/node": "^25.6.0", + "@types/node": "^25.7.0", "typescript": "^6.0.3" } }, @@ -47,6 +47,13 @@ "octo-sequence": "x/octo-sequence.js" } }, + "node_modules/@e280/octo/node_modules/@e280/stz": { + "version": "0.2.36", + "resolved": "https://registry.npmjs.org/@e280/stz/-/stz-0.2.36.tgz", + "integrity": "sha512-lA+CqHdWabjUEpxFzYfag8Pb76EM4RfRyK+PWqYL0gkrhHxCkAcjESWKioAwXHQiJB/cdiIlYWSLc6qcIL0WIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@e280/science": { "version": "0.1.11", "resolved": "https://registry.npmjs.org/@e280/science/-/science-0.1.11.tgz", @@ -71,20 +78,27 @@ "@e280/stz": "^0.2.34" } }, - "node_modules/@e280/stz": { + "node_modules/@e280/strata/node_modules/@e280/stz": { "version": "0.2.36", "resolved": "https://registry.npmjs.org/@e280/stz/-/stz-0.2.36.tgz", "integrity": "sha512-lA+CqHdWabjUEpxFzYfag8Pb76EM4RfRyK+PWqYL0gkrhHxCkAcjESWKioAwXHQiJB/cdiIlYWSLc6qcIL0WIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@e280/stz": { + "version": "0.3.0-next.0", + "resolved": "https://registry.npmjs.org/@e280/stz/-/stz-0.3.0-next.0.tgz", + "integrity": "sha512-4FgJ+ZVqbsOeblw2s+IwmwlZ5XVlMauBwftTyPDmtBjmOPiGKLyh/HdcKdtr2k0xY0lRYxJ5XCVk3uO5F+Icmg==", "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", + "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": "~7.21.0" } }, "node_modules/ansi-regex": { @@ -174,9 +188,9 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", + "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", "dev": true, "license": "MIT" } diff --git a/package.json b/package.json index 3fb907b..06fdeca 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,12 @@ "count": "find s -path '*/_archive' -prune -o -name '*.ts' -exec wc -l {} +" }, "dependencies": { - "@e280/stz": "^0.2.36" + "@e280/stz": "^0.3.0-next.0" }, "devDependencies": { "@e280/octo": "^0.1.1", "@e280/science": "^0.1.11", - "@types/node": "^25.6.0", + "@types/node": "^25.7.0", "typescript": "^6.0.3" }, "keywords": [ diff --git a/s/signals/r/map.ts b/s/signals/r/map.ts index f3cc9ad..9cb42ef 100644 --- a/s/signals/r/map.ts +++ b/s/signals/r/map.ts @@ -1,9 +1,7 @@ -import {GMap} from "@e280/stz" -import {batch} from "../batch.js" import {tracker} from "../../tracker/global.js" -export class RMap extends GMap { +export class RMap extends Map { // // reading @@ -67,10 +65,6 @@ export class RMap extends GMap { return ret } - setEntries(entries: Iterable<[K, V]>) { - return batch(() => super.setEntries(entries)) - } - clear() { super.clear() tracker.write(this) diff --git a/s/signals/r/set.ts b/s/signals/r/set.ts index 02aac14..6015485 100644 --- a/s/signals/r/set.ts +++ b/s/signals/r/set.ts @@ -1,8 +1,7 @@ -import {GSet} from "@e280/stz" import {tracker} from "../../tracker/global.js" -export class RSet extends GSet { +export class RSet extends Set { // // reading diff --git a/s/tracker/tracker.ts b/s/tracker/tracker.ts index 74112b7..7d8d7e1 100644 --- a/s/tracker/tracker.ts +++ b/s/tracker/tracker.ts @@ -1,5 +1,5 @@ -import {GWeakMap} from "@e280/stz" +import {guarantee} from "@e280/stz" export type Trackable = object | symbol @@ -9,7 +9,7 @@ export type Trackable = object | symbol export class Tracker { #busy = new Set<() => void>() #observationLayers: Set[] = [] - #subscriptions = new GWeakMap void>>() + #subscriptions = new WeakMap void>>() #batchDepth = 0 #batchPending = new Set<() => void>() @@ -47,7 +47,7 @@ export class Tracker { /** fn will be called when item changes */ subscribe(item: Item, fn: () => void) { - const fns = this.#subscriptions.guarantee(item, () => new Set()) + const fns = guarantee(this.#subscriptions, item, () => new Set()) fns.add(fn) return () => { fns.delete(fn) From e8fefe0c93de3bde7ac64a8d18709b6835b3c5d2 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Tue, 12 May 2026 15:50:29 -0700 Subject: [PATCH 46/48] 0.4.0-next.7 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 88973d4..be8335e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@e280/strata", - "version": "0.4.0-next.6", + "version": "0.4.0-next.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@e280/strata", - "version": "0.4.0-next.6", + "version": "0.4.0-next.7", "license": "MIT", "dependencies": { "@e280/stz": "^0.3.0-next.0" diff --git a/package.json b/package.json index 06fdeca..0a297e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@e280/strata", - "version": "0.4.0-next.6", + "version": "0.4.0-next.7", "description": "state management", "license": "MIT", "author": "Chase Moskal ", From 6f39fd9bf3b4099d0520d10180d59f804185a6d8 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Thu, 14 May 2026 13:10:21 -0700 Subject: [PATCH 47/48] update: deps --- package-lock.json | 24 ++++++++++++------------ package.json | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index be8335e..e74bba0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,12 @@ "version": "0.4.0-next.7", "license": "MIT", "dependencies": { - "@e280/stz": "^0.3.0-next.0" + "@e280/stz": "0.3.0-next.1" }, "devDependencies": { "@e280/octo": "^0.1.1", "@e280/science": "^0.1.11", - "@types/node": "^25.7.0", + "@types/node": "^25.8.0", "typescript": "^6.0.3" } }, @@ -86,19 +86,19 @@ "license": "MIT" }, "node_modules/@e280/stz": { - "version": "0.3.0-next.0", - "resolved": "https://registry.npmjs.org/@e280/stz/-/stz-0.3.0-next.0.tgz", - "integrity": "sha512-4FgJ+ZVqbsOeblw2s+IwmwlZ5XVlMauBwftTyPDmtBjmOPiGKLyh/HdcKdtr2k0xY0lRYxJ5XCVk3uO5F+Icmg==", + "version": "0.3.0-next.1", + "resolved": "https://registry.npmjs.org/@e280/stz/-/stz-0.3.0-next.1.tgz", + "integrity": "sha512-QDwen0GyDITbOiZSUKcpAdrLqdJ/qr5UGkzOff+K0aKSTp57UmDfFzH6LLD2Hhddf//GRBBoX8tU2BWUuMYzRw==", "license": "MIT" }, "node_modules/@types/node": { - "version": "25.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", - "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.21.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/ansi-regex": { @@ -188,9 +188,9 @@ } }, "node_modules/undici-types": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", - "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" } diff --git a/package.json b/package.json index 0a297e5..5ffba0d 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,12 @@ "count": "find s -path '*/_archive' -prune -o -name '*.ts' -exec wc -l {} +" }, "dependencies": { - "@e280/stz": "^0.3.0-next.0" + "@e280/stz": "0.3.0-next.1" }, "devDependencies": { "@e280/octo": "^0.1.1", "@e280/science": "^0.1.11", - "@types/node": "^25.7.0", + "@types/node": "^25.8.0", "typescript": "^6.0.3" }, "keywords": [ From 9e99ef6abd2398a0748415a1260c80351ebddf02 Mon Sep 17 00:00:00 2001 From: Chase Moskal Date: Thu, 14 May 2026 13:10:29 -0700 Subject: [PATCH 48/48] 0.4.0-next.8 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e74bba0..36b8381 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@e280/strata", - "version": "0.4.0-next.7", + "version": "0.4.0-next.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@e280/strata", - "version": "0.4.0-next.7", + "version": "0.4.0-next.8", "license": "MIT", "dependencies": { "@e280/stz": "0.3.0-next.1" diff --git a/package.json b/package.json index 5ffba0d..756da4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@e280/strata", - "version": "0.4.0-next.7", + "version": "0.4.0-next.8", "description": "state management", "license": "MIT", "author": "Chase Moskal ",