docs: move experiment records to the quark repo
This commit is contained in:
parent
6febfccd1e
commit
1dfacc2be9
11 changed files with 876 additions and 1565 deletions
33
packages/quark/THIRD_PARTY_NOTICES.md
Normal file
33
packages/quark/THIRD_PARTY_NOTICES.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Third-Party Notices
|
||||
|
||||
## alien-signals
|
||||
|
||||
The internal reactive kernel in `src/reactivity.ts` adapts the dependency
|
||||
graph algorithm of [alien-signals](https://github.com/stackblitz/alien-signals)
|
||||
3.2.1: the intrusive doubly-linked dependency and subscriber links with
|
||||
versioned in-place reuse, and the iterative `propagate` and `checkDirty`
|
||||
graph walks. alien-signals is not a runtime dependency of Quark.
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-present Johnson Chu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
"test": "bun --conditions=browser test"
|
||||
},
|
||||
"dependencies": {
|
||||
"alien-signals": "3.2.1",
|
||||
"solid-js": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ export namespace Keyed {
|
|||
|
||||
function publish(slot: Writable<A>, value: A) {
|
||||
const current = slot()
|
||||
// alien-signals uses SameValueZero-compatible identity for primitive writes.
|
||||
// The reactive kernel uses strict identity for primitive writes.
|
||||
if (current === value || equivalent(current, value)) {
|
||||
if (options.metrics) options.metrics.equivalenceSuppressions++
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { computed, effect, endBatch, setActiveSub, signal, startBatch } from "alien-signals"
|
||||
|
||||
export interface Readable<A> {
|
||||
(): A
|
||||
subscribe(listener: (value: A) => void): () => void
|
||||
|
|
@ -10,57 +8,466 @@ export interface Writable<A> extends Readable<A> {
|
|||
update(f: (value: A) => A): void
|
||||
}
|
||||
|
||||
function subscribe<A>(read: () => A, listener: (value: A) => void) {
|
||||
let initialized = false
|
||||
return effect(() => {
|
||||
const value = read()
|
||||
if (!initialized) {
|
||||
initialized = true
|
||||
return
|
||||
}
|
||||
const active = setActiveSub()
|
||||
try {
|
||||
listener(value)
|
||||
} finally {
|
||||
setActiveSub(active)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export namespace State {
|
||||
export function make<A>(initial: A): Writable<A> {
|
||||
const state = signal(initial)
|
||||
const read = (() => state()) as Writable<A>
|
||||
read.set = (value) => state(value)
|
||||
read.update = (f) => {
|
||||
// Read untracked: calling update inside an effect must not make the
|
||||
// effect depend on (and re-trigger from) this signal.
|
||||
const active = setActiveSub()
|
||||
const current = state()
|
||||
setActiveSub(active)
|
||||
state(f(current))
|
||||
const node: StateNode<A> = {
|
||||
flags: Flags.Mutable,
|
||||
value: initial,
|
||||
pending: initial,
|
||||
deps: undefined,
|
||||
depsTail: undefined,
|
||||
subs: undefined,
|
||||
subsTail: undefined,
|
||||
}
|
||||
read.subscribe = (listener) => subscribe(read, listener)
|
||||
const read = (() => readState(node)) as Writable<A>
|
||||
read.set = (value) => writeState(node, value)
|
||||
read.update = (f) => {
|
||||
// Read untracked: calling update inside a tracked evaluation must not
|
||||
// make the caller depend on (and re-trigger from) this state.
|
||||
const previous = swapActiveSub(undefined)
|
||||
try {
|
||||
writeState(node, f(readState(node)))
|
||||
} finally {
|
||||
activeSub = previous
|
||||
}
|
||||
}
|
||||
read.subscribe = (listener) => subscribeNode(node, read, listener)
|
||||
return read
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Computed {
|
||||
export function make<A>(evaluate: (previous: A | undefined) => A): Readable<A> {
|
||||
const value = computed(evaluate)
|
||||
const read = (() => value()) as Readable<A>
|
||||
read.subscribe = (listener) => subscribe(read, listener)
|
||||
const node: ComputedNode<A> = {
|
||||
flags: Flags.None,
|
||||
value: undefined,
|
||||
evaluate,
|
||||
deps: undefined,
|
||||
depsTail: undefined,
|
||||
subs: undefined,
|
||||
subsTail: undefined,
|
||||
}
|
||||
const read = (() => readComputed(node)) as Readable<A>
|
||||
read.subscribe = (listener) => subscribeNode(node, read, listener)
|
||||
return read
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Transaction {
|
||||
export function run<A>(f: () => A): A {
|
||||
startBatch()
|
||||
batchDepth++
|
||||
try {
|
||||
return f()
|
||||
} finally {
|
||||
endBatch()
|
||||
if (!--batchDepth) flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Internal reactive kernel.
|
||||
//
|
||||
// The dependency graph representation (intrusive doubly-linked dependency and
|
||||
// subscriber links with versioned in-place reuse) and the iterative
|
||||
// `propagate`/`checkDirty` walks are adapted from alien-signals 3.2.1
|
||||
// (https://github.com/stackblitz/alien-signals, MIT). See
|
||||
// THIRD_PARTY_NOTICES.md. Quark departs from the reference in three ways:
|
||||
// subscribers are fixed single-dependency watchers instead of general
|
||||
// effects, orphaned computeds are detached with an iterative work queue
|
||||
// instead of recursive unwatch callbacks, and reading a computed that is
|
||||
// currently evaluating throws a stable cycle error instead of looping
|
||||
// (stackblitz/alien-signals#118, #123).
|
||||
|
||||
const enum Flags {
|
||||
None = 0,
|
||||
/** The node can produce a new value: states always, computeds once evaluated. */
|
||||
Mutable = 1,
|
||||
/** The node is a subscription watcher delivering values to a listener. */
|
||||
Watching = 2,
|
||||
/** The computed is currently evaluating; reading it again is a cycle. */
|
||||
Computing = 4,
|
||||
/** The watcher is queued for the next flush. */
|
||||
Queued = 8,
|
||||
/** The node's value is known stale. */
|
||||
Dirty = 16,
|
||||
/** The node's value is possibly stale pending dependency revalidation. */
|
||||
Pending = 32,
|
||||
}
|
||||
|
||||
interface ReactiveNode {
|
||||
flags: Flags
|
||||
deps?: Link
|
||||
depsTail?: Link
|
||||
subs?: Link
|
||||
subsTail?: Link
|
||||
}
|
||||
|
||||
interface StateNode<A = unknown> extends ReactiveNode {
|
||||
value: A
|
||||
pending: A
|
||||
}
|
||||
|
||||
interface ComputedNode<A = unknown> extends ReactiveNode {
|
||||
value: A | undefined
|
||||
evaluate: (previous: A | undefined) => A
|
||||
}
|
||||
|
||||
interface WatcherNode extends ReactiveNode {
|
||||
read: () => unknown
|
||||
listener: (value: unknown) => void
|
||||
}
|
||||
|
||||
interface Link {
|
||||
version: number
|
||||
dep: ReactiveNode
|
||||
sub: ReactiveNode
|
||||
prevSub: Link | undefined
|
||||
nextSub: Link | undefined
|
||||
prevDep: Link | undefined
|
||||
nextDep: Link | undefined
|
||||
}
|
||||
|
||||
interface Stack<A> {
|
||||
value: A
|
||||
prev: Stack<A> | undefined
|
||||
}
|
||||
|
||||
let activeSub: ReactiveNode | undefined
|
||||
let batchDepth = 0
|
||||
let version = 0
|
||||
let flushIndex = 0
|
||||
let queueLength = 0
|
||||
const queue: Array<WatcherNode | undefined> = []
|
||||
const orphans: Array<ReactiveNode> = []
|
||||
|
||||
function swapActiveSub(sub: ReactiveNode | undefined): ReactiveNode | undefined {
|
||||
const previous = activeSub
|
||||
activeSub = sub
|
||||
return previous
|
||||
}
|
||||
|
||||
function readState<A>(node: StateNode<A>): A {
|
||||
if (node.flags & Flags.Dirty && updateState(node) && node.subs !== undefined) {
|
||||
shallowPropagate(node.subs)
|
||||
}
|
||||
if (activeSub !== undefined) link(node, activeSub, version)
|
||||
return node.value
|
||||
}
|
||||
|
||||
function writeState<A>(node: StateNode<A>, value: A): void {
|
||||
if (node.pending === (node.pending = value)) return
|
||||
node.flags = Flags.Mutable | Flags.Dirty
|
||||
if (node.subs !== undefined) {
|
||||
propagate(node.subs)
|
||||
if (!batchDepth) flush()
|
||||
}
|
||||
}
|
||||
|
||||
function readComputed<A>(node: ComputedNode<A>): A {
|
||||
const flags = node.flags
|
||||
if (flags & Flags.Computing) {
|
||||
throw new Error("Reactive cycle detected: a computed depends on its own value")
|
||||
}
|
||||
if (
|
||||
flags & Flags.Dirty ||
|
||||
(flags & Flags.Pending &&
|
||||
(checkDirty(node.deps!, node) || ((node.flags = flags & ~Flags.Pending), false))) ||
|
||||
!flags
|
||||
) {
|
||||
if (updateComputed(node) && node.subs !== undefined) {
|
||||
shallowPropagate(node.subs)
|
||||
}
|
||||
}
|
||||
if (activeSub !== undefined) link(node, activeSub, version)
|
||||
return node.value!
|
||||
}
|
||||
|
||||
function updateState(node: StateNode): boolean {
|
||||
node.flags = Flags.Mutable
|
||||
return node.value !== (node.value = node.pending)
|
||||
}
|
||||
|
||||
function updateComputed<A>(node: ComputedNode<A>): boolean {
|
||||
node.depsTail = undefined
|
||||
node.flags = Flags.Mutable | Flags.Computing
|
||||
const previous = swapActiveSub(node)
|
||||
version++
|
||||
let completed = false
|
||||
try {
|
||||
const oldValue = node.value
|
||||
const changed = oldValue !== (node.value = node.evaluate(oldValue))
|
||||
completed = true
|
||||
return changed
|
||||
} finally {
|
||||
activeSub = previous
|
||||
node.flags &= ~Flags.Computing
|
||||
// A throwing evaluation stays dirty so the next read retries instead of
|
||||
// serving a stale value with no dependency links.
|
||||
if (!completed) node.flags |= Flags.Dirty
|
||||
purgeDeps(node)
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeNode<A>(node: ReactiveNode, read: () => A, listener: (value: A) => void): () => void {
|
||||
// Evaluate untracked before linking so a throwing computed leaves no
|
||||
// partially initialized watcher behind.
|
||||
const previous = swapActiveSub(undefined)
|
||||
try {
|
||||
read()
|
||||
} finally {
|
||||
activeSub = previous
|
||||
}
|
||||
const watcher: WatcherNode = {
|
||||
flags: Flags.Watching,
|
||||
read,
|
||||
listener: listener as (value: unknown) => void,
|
||||
deps: undefined,
|
||||
depsTail: undefined,
|
||||
}
|
||||
link(node, watcher, ++version)
|
||||
return () => {
|
||||
if (!(watcher.flags & Flags.Watching)) return
|
||||
watcher.flags &= ~(Flags.Watching | Flags.Dirty | Flags.Pending)
|
||||
unlink(watcher.deps!, watcher)
|
||||
drainOrphans()
|
||||
}
|
||||
}
|
||||
|
||||
function runWatcher(watcher: WatcherNode): void {
|
||||
const flags = watcher.flags
|
||||
watcher.flags = flags & ~(Flags.Queued | Flags.Dirty | Flags.Pending)
|
||||
if (!(flags & Flags.Watching)) return
|
||||
const dirty =
|
||||
!!(flags & Flags.Dirty) || (!!(flags & Flags.Pending) && checkDirty(watcher.deps!, watcher))
|
||||
// Revalidation runs user code in computed evaluations, which may dispose
|
||||
// this watcher; a disposed watcher must not deliver.
|
||||
if (!dirty || !(watcher.flags & Flags.Watching)) return
|
||||
// Listener reads stay untracked and listeners may dispose subscriptions,
|
||||
// including their own.
|
||||
const previous = swapActiveSub(undefined)
|
||||
try {
|
||||
watcher.listener(watcher.read())
|
||||
} finally {
|
||||
activeSub = previous
|
||||
}
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
try {
|
||||
while (flushIndex < queueLength) {
|
||||
const watcher = queue[flushIndex]!
|
||||
queue[flushIndex++] = undefined
|
||||
runWatcher(watcher)
|
||||
}
|
||||
} finally {
|
||||
// A throwing listener aborts this flush; the remaining watchers keep
|
||||
// their Dirty/Pending flags and requeue on the next propagation.
|
||||
while (flushIndex < queueLength) {
|
||||
const watcher = queue[flushIndex]!
|
||||
queue[flushIndex++] = undefined
|
||||
watcher.flags &= ~Flags.Queued
|
||||
}
|
||||
flushIndex = 0
|
||||
queueLength = 0
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(watcher: WatcherNode): void {
|
||||
watcher.flags |= Flags.Queued
|
||||
queue[queueLength++] = watcher
|
||||
}
|
||||
|
||||
function link(dep: ReactiveNode, sub: ReactiveNode, linkVersion: number): void {
|
||||
const prevDep = sub.depsTail
|
||||
if (prevDep !== undefined && prevDep.dep === dep) return
|
||||
const nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps
|
||||
if (nextDep !== undefined && nextDep.dep === dep) {
|
||||
nextDep.version = linkVersion
|
||||
sub.depsTail = nextDep
|
||||
return
|
||||
}
|
||||
const prevSub = dep.subsTail
|
||||
if (prevSub !== undefined && prevSub.version === linkVersion && prevSub.sub === sub) return
|
||||
const newLink: Link = {
|
||||
version: linkVersion,
|
||||
dep,
|
||||
sub,
|
||||
prevDep,
|
||||
nextDep,
|
||||
prevSub,
|
||||
nextSub: undefined,
|
||||
}
|
||||
sub.depsTail = newLink
|
||||
dep.subsTail = newLink
|
||||
if (nextDep !== undefined) nextDep.prevDep = newLink
|
||||
if (prevDep !== undefined) prevDep.nextDep = newLink
|
||||
else sub.deps = newLink
|
||||
if (prevSub !== undefined) prevSub.nextSub = newLink
|
||||
else dep.subs = newLink
|
||||
}
|
||||
|
||||
function unlink(current: Link, sub: ReactiveNode): Link | undefined {
|
||||
const dep = current.dep
|
||||
const prevDep = current.prevDep
|
||||
const nextDep = current.nextDep
|
||||
const nextSub = current.nextSub
|
||||
const prevSub = current.prevSub
|
||||
if (nextDep !== undefined) nextDep.prevDep = prevDep
|
||||
else sub.depsTail = prevDep
|
||||
if (prevDep !== undefined) prevDep.nextDep = nextDep
|
||||
else sub.deps = nextDep
|
||||
if (nextSub !== undefined) nextSub.prevSub = prevSub
|
||||
else dep.subsTail = prevSub
|
||||
if (prevSub !== undefined) prevSub.nextSub = nextSub
|
||||
else if ((dep.subs = nextSub) === undefined && dep.deps !== undefined) orphans.push(dep)
|
||||
return nextDep
|
||||
}
|
||||
|
||||
// Detaches computeds that lost their last subscriber. Iterative on purpose:
|
||||
// a recursive unwatch cascade overflows the stack on deep chains. Only nodes
|
||||
// with dependencies enter the queue (states and dep-less computeds have
|
||||
// nothing to detach). As in the reference, a computed that is read but never
|
||||
// subscribed stays linked to its sources until they are collected; create
|
||||
// long-lived computeds rather than per-operation ones.
|
||||
function drainOrphans(): void {
|
||||
while (orphans.length > 0) {
|
||||
const node = orphans.pop()!
|
||||
if (!("evaluate" in node) || node.depsTail === undefined) continue
|
||||
node.flags = Flags.Mutable | Flags.Dirty
|
||||
let current = node.depsTail as Link | undefined
|
||||
while (current !== undefined) {
|
||||
const prev = current.prevDep
|
||||
unlink(current, node)
|
||||
current = prev
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function purgeDeps(sub: ReactiveNode): void {
|
||||
const depsTail = sub.depsTail
|
||||
let dep = depsTail !== undefined ? depsTail.nextDep : sub.deps
|
||||
while (dep !== undefined) {
|
||||
dep = unlink(dep, sub)
|
||||
}
|
||||
drainOrphans()
|
||||
}
|
||||
|
||||
function propagate(current: Link): void {
|
||||
let next = current.nextSub
|
||||
let stack: Stack<Link | undefined> | undefined
|
||||
|
||||
top: do {
|
||||
const sub = current.sub
|
||||
const flags = sub.flags
|
||||
const unmarked = !(flags & (Flags.Dirty | Flags.Pending))
|
||||
if (unmarked) sub.flags = flags | Flags.Pending
|
||||
if (flags & Flags.Watching && !(flags & Flags.Queued)) enqueue(sub as WatcherNode)
|
||||
|
||||
if (unmarked && flags & Flags.Mutable) {
|
||||
const subSubs = sub.subs
|
||||
if (subSubs !== undefined) {
|
||||
current = subSubs
|
||||
const nextSub = subSubs.nextSub
|
||||
if (nextSub !== undefined) {
|
||||
stack = { value: next, prev: stack }
|
||||
next = nextSub
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (next !== undefined) {
|
||||
current = next
|
||||
next = current.nextSub
|
||||
continue
|
||||
}
|
||||
|
||||
while (stack !== undefined) {
|
||||
const continuation = stack.value
|
||||
stack = stack.prev
|
||||
if (continuation !== undefined) {
|
||||
current = continuation
|
||||
next = current.nextSub
|
||||
continue top
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
} while (true)
|
||||
}
|
||||
|
||||
function shallowPropagate(current: Link): void {
|
||||
let iterator: Link | undefined = current
|
||||
do {
|
||||
const sub = iterator.sub
|
||||
const flags = sub.flags
|
||||
if ((flags & (Flags.Pending | Flags.Dirty)) === Flags.Pending) {
|
||||
sub.flags = flags | Flags.Dirty
|
||||
if (flags & Flags.Watching && !(flags & Flags.Queued)) enqueue(sub as WatcherNode)
|
||||
}
|
||||
iterator = iterator.nextSub
|
||||
} while (iterator !== undefined)
|
||||
}
|
||||
|
||||
function updateNode(node: ReactiveNode): boolean {
|
||||
if ("evaluate" in node) return updateComputed(node as ComputedNode)
|
||||
return updateState(node as StateNode)
|
||||
}
|
||||
|
||||
function checkDirty(current: Link, sub: ReactiveNode): boolean {
|
||||
let stack: Stack<Link> | undefined
|
||||
let checkDepth = 0
|
||||
let dirty = false
|
||||
|
||||
top: do {
|
||||
const dep = current.dep
|
||||
const flags = dep.flags
|
||||
|
||||
if (sub.flags & Flags.Dirty) {
|
||||
dirty = true
|
||||
} else if ((flags & (Flags.Mutable | Flags.Dirty)) === (Flags.Mutable | Flags.Dirty)) {
|
||||
const subs = dep.subs!
|
||||
if (updateNode(dep)) {
|
||||
if (subs.nextSub !== undefined) shallowPropagate(subs)
|
||||
dirty = true
|
||||
}
|
||||
} else if ((flags & (Flags.Mutable | Flags.Pending)) === (Flags.Mutable | Flags.Pending)) {
|
||||
stack = { value: current, prev: stack }
|
||||
current = dep.deps!
|
||||
sub = dep
|
||||
checkDepth++
|
||||
continue
|
||||
}
|
||||
|
||||
if (!dirty) {
|
||||
const nextDep = current.nextDep
|
||||
if (nextDep !== undefined) {
|
||||
current = nextDep
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
while (checkDepth--) {
|
||||
current = stack!.value
|
||||
stack = stack!.prev
|
||||
if (dirty) {
|
||||
const subs = sub.subs!
|
||||
if (updateNode(sub)) {
|
||||
if (subs.nextSub !== undefined) shallowPropagate(subs)
|
||||
sub = current.sub
|
||||
continue
|
||||
}
|
||||
dirty = false
|
||||
} else {
|
||||
sub.flags &= ~Flags.Pending
|
||||
}
|
||||
sub = current.sub
|
||||
const nextDep = current.nextDep
|
||||
if (nextDep !== undefined) {
|
||||
current = nextDep
|
||||
continue top
|
||||
}
|
||||
}
|
||||
|
||||
return dirty
|
||||
} while (true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,110 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { createEffect, createRoot } from "solid-js"
|
||||
import { State } from "../src"
|
||||
import { useValue } from "../src/solid"
|
||||
import { Computed, State, Transaction, type Readable } from "../src/reactivity"
|
||||
|
||||
describe("reactivity", () => {
|
||||
it("keeps computed values lazy", () => {
|
||||
const source = State.make(1)
|
||||
let evaluations = 0
|
||||
const doubled = Computed.make(() => {
|
||||
evaluations++
|
||||
return source() * 2
|
||||
})
|
||||
|
||||
expect(evaluations).toBe(0)
|
||||
expect(doubled()).toBe(2)
|
||||
expect(doubled()).toBe(2)
|
||||
expect(evaluations).toBe(1)
|
||||
|
||||
source.set(2)
|
||||
expect(evaluations).toBe(1)
|
||||
expect(doubled()).toBe(4)
|
||||
expect(evaluations).toBe(2)
|
||||
})
|
||||
|
||||
it("propagates a diamond without glitches", () => {
|
||||
const source = State.make(1)
|
||||
const left = Computed.make(() => source() * 2)
|
||||
const right = Computed.make(() => source() * 3)
|
||||
const total = Computed.make(() => left() + right())
|
||||
const values: Array<number> = []
|
||||
const dispose = total.subscribe((value) => values.push(value))
|
||||
|
||||
source.set(2)
|
||||
|
||||
expect(values).toEqual([10])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("tracks dynamic dependencies", () => {
|
||||
const enabled = State.make(true)
|
||||
const left = State.make(1)
|
||||
const right = State.make(10)
|
||||
const selected = Computed.make(() => (enabled() ? left() : right()))
|
||||
const values: Array<number> = []
|
||||
const dispose = selected.subscribe((value) => values.push(value))
|
||||
|
||||
left.set(2)
|
||||
enabled.set(false)
|
||||
left.set(3)
|
||||
right.set(11)
|
||||
|
||||
expect(values).toEqual([2, 10, 11])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("batches transactions", () => {
|
||||
const left = State.make(1)
|
||||
const right = State.make(2)
|
||||
const total = Computed.make(() => left() + right())
|
||||
const values: Array<number> = []
|
||||
const dispose = total.subscribe((value) => values.push(value))
|
||||
|
||||
Transaction.run(() => {
|
||||
left.set(2)
|
||||
right.set(3)
|
||||
})
|
||||
|
||||
expect(values).toEqual([5])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("cuts off unchanged derived values", () => {
|
||||
const source = State.make(1)
|
||||
let parityEvaluations = 0
|
||||
let labelEvaluations = 0
|
||||
const parity = Computed.make(() => {
|
||||
parityEvaluations++
|
||||
return source() % 2
|
||||
})
|
||||
const label = Computed.make(() => {
|
||||
labelEvaluations++
|
||||
return parity() === 0 ? "even" : "odd"
|
||||
})
|
||||
const dispose = label.subscribe(() => {})
|
||||
|
||||
source.set(3)
|
||||
|
||||
expect(parityEvaluations).toBe(2)
|
||||
expect(labelEvaluations).toBe(1)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("disposes subscriptions", () => {
|
||||
const source = State.make(1)
|
||||
const values: Array<number> = []
|
||||
const dispose = source.subscribe((value) => values.push(value))
|
||||
|
||||
source.set(2)
|
||||
dispose()
|
||||
source.set(3)
|
||||
|
||||
expect(values).toEqual([2])
|
||||
})
|
||||
|
||||
describe("Quark", () => {
|
||||
it("does not track state read by subscription listeners", () => {
|
||||
const source = State.make(1)
|
||||
const unrelated = State.make(1)
|
||||
const values: number[] = []
|
||||
const values: Array<number> = []
|
||||
const dispose = source.subscribe((value) => {
|
||||
unrelated()
|
||||
values.push(value)
|
||||
|
|
@ -20,20 +117,311 @@ describe("Quark", () => {
|
|||
dispose()
|
||||
})
|
||||
|
||||
it("bridges values into a Solid owner and disposes with it", () => {
|
||||
it("does not track the state read by update", () => {
|
||||
const trigger = State.make(0)
|
||||
const updated = State.make(0)
|
||||
let runs = 0
|
||||
const dispose = trigger.subscribe(() => {
|
||||
runs++
|
||||
updated.update((value) => value + 1)
|
||||
})
|
||||
|
||||
trigger.set(1)
|
||||
updated.set(10)
|
||||
|
||||
expect(runs).toBe(1)
|
||||
expect(updated()).toBe(10)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("delivers the previous value to computed evaluation", () => {
|
||||
const source = State.make(1)
|
||||
const values: number[] = []
|
||||
const previousValues: Array<number | undefined> = []
|
||||
const running = Computed.make<number>((previous) => {
|
||||
previousValues.push(previous)
|
||||
return (previous ?? 0) + source()
|
||||
})
|
||||
|
||||
expect(running()).toBe(1)
|
||||
source.set(2)
|
||||
expect(running()).toBe(3)
|
||||
source.set(3)
|
||||
expect(running()).toBe(6)
|
||||
expect(previousValues).toEqual([undefined, 1, 3])
|
||||
})
|
||||
|
||||
it("detaches and reattaches dynamic dependencies", () => {
|
||||
const enabled = State.make(true)
|
||||
const left = State.make(1)
|
||||
const right = State.make(10)
|
||||
let evaluations = 0
|
||||
const selected = Computed.make(() => {
|
||||
evaluations++
|
||||
return enabled() ? left() : right()
|
||||
})
|
||||
const dispose = selected.subscribe(() => {})
|
||||
|
||||
expect(evaluations).toBe(1)
|
||||
enabled.set(false)
|
||||
expect(evaluations).toBe(2)
|
||||
|
||||
// Detached: left writes must not re-evaluate the computed.
|
||||
left.set(2)
|
||||
left.set(3)
|
||||
expect(evaluations).toBe(2)
|
||||
|
||||
// Reattached: left writes re-evaluate, right writes no longer do.
|
||||
enabled.set(true)
|
||||
expect(evaluations).toBe(3)
|
||||
expect(selected()).toBe(3)
|
||||
right.set(11)
|
||||
expect(evaluations).toBe(3)
|
||||
left.set(4)
|
||||
expect(evaluations).toBe(4)
|
||||
expect(selected()).toBe(4)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("notifies a diamond subscriber exactly once per write", () => {
|
||||
const source = State.make(1)
|
||||
const left = Computed.make(() => source() * 2)
|
||||
const right = Computed.make(() => source() * 3)
|
||||
const total = Computed.make(() => left() + right())
|
||||
let notifications = 0
|
||||
const dispose = total.subscribe(() => notifications++)
|
||||
|
||||
source.set(2)
|
||||
source.set(3)
|
||||
|
||||
expect(notifications).toBe(2)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("batches nested transactions until the outermost ends", () => {
|
||||
const left = State.make(1)
|
||||
const right = State.make(2)
|
||||
const total = Computed.make(() => left() + right())
|
||||
const values: Array<number> = []
|
||||
const dispose = total.subscribe((value) => values.push(value))
|
||||
|
||||
Transaction.run(() => {
|
||||
left.set(2)
|
||||
Transaction.run(() => {
|
||||
right.set(3)
|
||||
})
|
||||
expect(values).toEqual([])
|
||||
left.set(3)
|
||||
})
|
||||
|
||||
expect(values).toEqual([6])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("restores batch state when a transaction throws", () => {
|
||||
const source = State.make(1)
|
||||
const values: Array<number> = []
|
||||
const dispose = source.subscribe((value) => values.push(value))
|
||||
|
||||
expect(() =>
|
||||
Transaction.run(() => {
|
||||
source.set(2)
|
||||
throw new Error("boom")
|
||||
}),
|
||||
).toThrow("boom")
|
||||
|
||||
// The write that happened before the throw still flushes once the
|
||||
// transaction unwinds, and later writes are not batched.
|
||||
expect(values).toEqual([2])
|
||||
source.set(3)
|
||||
expect(values).toEqual([2, 3])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("supports disposing another subscription during notification", () => {
|
||||
const source = State.make(1)
|
||||
const first: Array<number> = []
|
||||
const second: Array<number> = []
|
||||
let disposeSecond = () => {}
|
||||
const disposeFirst = source.subscribe((value) => {
|
||||
first.push(value)
|
||||
disposeSecond()
|
||||
})
|
||||
disposeSecond = source.subscribe((value) => second.push(value))
|
||||
|
||||
source.set(2)
|
||||
source.set(3)
|
||||
|
||||
expect(first).toEqual([2, 3])
|
||||
expect(second).toEqual([])
|
||||
disposeFirst()
|
||||
})
|
||||
|
||||
it("does not deliver to a watcher disposed during revalidation", () => {
|
||||
const source = State.make(0)
|
||||
let dispose = () => {}
|
||||
createRoot((rootDispose) => {
|
||||
dispose = rootDispose
|
||||
const value = useValue(source)
|
||||
createEffect(() => values.push(value()))
|
||||
const gate = Computed.make(() => {
|
||||
const value = source()
|
||||
if (value > 0) dispose()
|
||||
return value
|
||||
})
|
||||
const values: Array<number> = []
|
||||
dispose = gate.subscribe((value) => values.push(value))
|
||||
|
||||
// Revalidating the watcher re-evaluates gate, whose evaluation disposes
|
||||
// the subscription before the value could be delivered.
|
||||
source.set(1)
|
||||
source.set(2)
|
||||
|
||||
expect(values).toEqual([])
|
||||
})
|
||||
|
||||
it("supports a subscription disposing itself during notification", () => {
|
||||
const source = State.make(1)
|
||||
const values: Array<number> = []
|
||||
let dispose = () => {}
|
||||
dispose = source.subscribe((value) => {
|
||||
values.push(value)
|
||||
dispose()
|
||||
})
|
||||
|
||||
source.set(2)
|
||||
dispose()
|
||||
source.set(3)
|
||||
|
||||
expect(values).toEqual([1, 2])
|
||||
expect(values).toEqual([2])
|
||||
})
|
||||
|
||||
it("does not track State.update reading its own value", () => {
|
||||
const counter = State.make(0)
|
||||
const source = State.make(1)
|
||||
let evaluations = 0
|
||||
const tracked = Computed.make(() => {
|
||||
evaluations++
|
||||
counter.update((value) => value)
|
||||
return source()
|
||||
})
|
||||
const dispose = tracked.subscribe(() => {})
|
||||
|
||||
expect(evaluations).toBe(1)
|
||||
counter.set(5)
|
||||
expect(evaluations).toBe(1)
|
||||
source.set(2)
|
||||
expect(evaluations).toBe(2)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("recovers tracking after a computed evaluation throws", () => {
|
||||
const shouldThrow = State.make(true)
|
||||
const source = State.make(1)
|
||||
const throwing = Computed.make(() => {
|
||||
if (shouldThrow()) throw new Error("computed boom")
|
||||
return source()
|
||||
})
|
||||
|
||||
expect(() => throwing()).toThrow("computed boom")
|
||||
|
||||
// The failed evaluation must restore the active observer so unrelated
|
||||
// graphs keep tracking correctly afterwards.
|
||||
const other = State.make(1)
|
||||
const doubled = Computed.make(() => other() * 2)
|
||||
const values: Array<number> = []
|
||||
const dispose = doubled.subscribe((value) => values.push(value))
|
||||
other.set(2)
|
||||
expect(values).toEqual([4])
|
||||
dispose()
|
||||
|
||||
shouldThrow.set(false)
|
||||
expect(throwing()).toBe(1)
|
||||
source.set(2)
|
||||
expect(throwing()).toBe(2)
|
||||
})
|
||||
|
||||
it("keeps notifying other subscribers after a listener throws", () => {
|
||||
const source = State.make(1)
|
||||
const values: Array<number> = []
|
||||
const disposeThrowing = source.subscribe(() => {
|
||||
throw new Error("listener boom")
|
||||
})
|
||||
const dispose = source.subscribe((value) => values.push(value))
|
||||
|
||||
expect(() => source.set(2)).toThrow("listener boom")
|
||||
disposeThrowing()
|
||||
source.set(3)
|
||||
|
||||
expect(values).toContain(3)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("leaves no dependency links behind when subscription initialization fails", () => {
|
||||
const source = State.make(1)
|
||||
const throwing = Computed.make(() => {
|
||||
if (source() === 1) throw new Error("init boom")
|
||||
return source()
|
||||
})
|
||||
const values: Array<number> = []
|
||||
|
||||
expect(() => throwing.subscribe((value) => values.push(value))).toThrow("init boom")
|
||||
|
||||
// The failed subscription must not stay linked to the graph.
|
||||
source.set(2)
|
||||
source.set(3)
|
||||
expect(values).toEqual([])
|
||||
|
||||
// The computed itself remains usable.
|
||||
expect(throwing()).toBe(3)
|
||||
})
|
||||
|
||||
it("propagates deep chains without recursive stack growth", () => {
|
||||
// A cold pull of an unevaluated chain necessarily nests user getter
|
||||
// frames, so each layer is evaluated as it is built. The kernel-owned
|
||||
// paths under test are dirty propagation and revalidation, which must
|
||||
// walk the full depth iteratively.
|
||||
const depth = 100_000
|
||||
const source = State.make(0)
|
||||
const chain = Array.from({ length: depth }).reduce<Readable<number>>((current) => {
|
||||
const next = Computed.make(() => current() + 1)
|
||||
next()
|
||||
return next
|
||||
}, source)
|
||||
|
||||
expect(chain()).toBe(depth)
|
||||
const values: Array<number> = []
|
||||
const dispose = chain.subscribe((value) => values.push(value))
|
||||
source.set(1)
|
||||
expect(values).toEqual([depth + 1])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("propagates wide fan-out without recursive stack growth", () => {
|
||||
const width = 50_000
|
||||
const source = State.make(0)
|
||||
const nodes = Array.from({ length: width }, () => Computed.make(() => source() + 1))
|
||||
const total = Computed.make(() => nodes.reduce((sum, node) => sum + node(), 0))
|
||||
let sink = 0
|
||||
const dispose = total.subscribe((value) => {
|
||||
sink = value
|
||||
})
|
||||
|
||||
source.set(1)
|
||||
expect(sink).toBe(width * 2)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("fails deterministically on cyclic computed dependencies", () => {
|
||||
// Regression for stackblitz/alien-signals#123: mutually dependent
|
||||
// computed graphs must throw a stable error instead of looping or
|
||||
// exhausting memory.
|
||||
const fieldA = State.make(false)
|
||||
const fieldB = State.make(false)
|
||||
const a: Readable<boolean | null> = Computed.make(() => (b() !== true ? fieldA() : null))
|
||||
const b: Readable<boolean | null> = Computed.make(() => (a() !== true ? fieldB() : null))
|
||||
|
||||
// Every read that reaches the cycle fails the same way; a failed
|
||||
// evaluation stays dirty and retries instead of serving a stale value.
|
||||
expect(() => a()).toThrow(/cycle/i)
|
||||
expect(() => b()).toThrow(/cycle/i)
|
||||
|
||||
fieldA.set(true)
|
||||
|
||||
expect(() => a()).toThrow(/cycle/i)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Keyed, Layout } from "effect-quark"
|
|||
* Streaming events name their part on the wire (text/reasoning by ordinal,
|
||||
* tools by callID); each assistant message owns one keyed collection so a
|
||||
* delta publishes exactly one slot instead of reconciling a content array.
|
||||
* See docs/design/quark-message-content.md.
|
||||
* Design record: quark repo, docs/experiments/quark-message-content.md.
|
||||
*/
|
||||
export namespace SessionContent {
|
||||
type ContentPart = SessionMessageAssistant["content"][number]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue