refactor!: remove peek

This commit is contained in:
littensy 2026-03-22 13:08:28 -07:00
parent 5c27052965
commit fd6b3efbb5
6 changed files with 70 additions and 262 deletions

View file

@ -50,7 +50,6 @@ Charm is a state management library based on [fine-grained reactivity](https://d
- [`observe(getter, callback)`](#observegetter-callback)
- [`subscribe(getter, callback)`](#subscribegetter-callback)
- [`untracked(callback)`](#untrackedcallback)
- [`peek(callback)`](#peekcallback)
- [`batch(callback)`](#batchcallback)
- [`mapped(getter, mapper)`](#mappedgetter-mapper)
- [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently)
@ -451,22 +450,6 @@ stopEffect() -- Cleaned up untracked effect
---
### `peek(callback)`
Similar to `untracked()`, but does not prevent the parent effect from tracking nested effects created in the callback. If your callback reads a signal and creates effects, only the signals get untracked.
```luau
local getCounter, setCounter = signal(0)
local disposeOuter = effect(function()
print(`Count is {peek(getCounter)}`)
end) -- Count is 0
setCounter(1) -- No output; count was accessed in peek()
```
---
### `batch(callback)`
Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes.

View file

@ -3,14 +3,13 @@ type Key = string | number | symbol;
type Cleanup = () => void;
export enum ReactiveFlags {
None = 0b0000000,
Mutable = 0b0000001,
Watching = 0b0000010,
RecursedCheck = 0b0000100,
Recursed = 0b0001000,
Dirty = 0b0010000,
Pending = 0b0100000,
Peeking = 0b1000000,
None = 0b000000,
Mutable = 0b000001,
Watching = 0b000010,
RecursedCheck = 0b000100,
Recursed = 0b001000,
Dirty = 0b010000,
Pending = 0b100000,
}
export interface ReactiveNode {
@ -154,7 +153,8 @@ export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
* current effect or scope from tracking inner effects created within the
* function.
*
* For an alternative that allows nested effect cleanup, use `peek` instead.
* To avoid tracking signals while still allowing inner effects to be tracked
* by parent effects/scopes, use `effectScope()` instead.
*
* @param fn A function that may read signals or create effects.
* @param args Arguments to pass to the function.
@ -163,21 +163,6 @@ export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
*/
export function untracked<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
/**
* Runs the function without subscribing to signal updates, but still allows
* inner effects to be tracked by parent effects/scopes.
*
* This is useful for cases where you want to access the current value of a
* signal without creating a dependency, but still want any effects created
* within the function to be cleaned up properly.
*
* @param fn A function that may read signals.
* @param args Arguments to pass to the function.
* @returns The return value of the function.
* @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
*/
export function peek<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
/**
* Combines multiple updates into a single "commit" that runs effects and
* computed signals after the provided function finishes running.

View file

@ -44,13 +44,12 @@ type SignalNode<T = any> = ReactiveNode & {
-- Bitmask flags for tracking the state of reactive nodes. Flags are combined
-- and checked using bitwise operations for efficiency.
local NONE = 0b0000000 -- Default state
local MUTABLE = 0b0000001 -- Can be changed (signal or computed)
local WATCHING = 0b0000010 -- Watching for changes (effects)
local RECURSED_CHECK = 0b0000100 -- Being checked for circular dependencies
local DIRTY = 0b0010000 -- Value has changed and needs updating
local PENDING = 0b0100000 -- Might be dirty, pending verification
local PEEKING = 0b1000000 -- Blocking dependency tracking
local NONE = 0b000000 -- Default state
local MUTABLE = 0b000001 -- Can be changed (signal or computed)
local WATCHING = 0b000010 -- Watching for changes (effects)
local RECURSED_CHECK = 0b000100 -- Being checked for circular dependencies
local DIRTY = 0b010000 -- Value has changed and needs updating
local PENDING = 0b100000 -- Might be dirty, pending verification
-- Global version counter for dependency link deduplication. Incremented at the
-- start of each effect/computed run to ensure links created in the same cycle
@ -703,7 +702,8 @@ end
current effect or scope from tracking inner effects created within the
function.
For an alternative that allows nested effect cleanup, use `peek` instead.
To avoid tracking signals while still allowing inner effects to be tracked
by parent effects/scopes, use `effectScope()` instead.
@param fn A signal or function that accesses signals
@param ...args Arguments to pass to the function
@ -724,39 +724,6 @@ local function untracked<Args..., Results...>(fn: (Args...) -> Results..., ...:
end
end
--[[
Runs the function without subscribing to signal updates, but still allows
inner effects to be tracked by parent effects/scopes.
This is useful for cases where you want to access the current value of a
signal without creating a dependency, but still want any effects created
within the function to be cleaned up properly.
To avoid capturing inner effects, use `untracked` instead.
@param fn A signal or function that accesses signals
@param ...args Arguments to pass to the function
@return Values returned by the function
@see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
]]
local function peek<Args..., Results...>(fn: (Args...) -> Results..., ...: Args...): Results...
if
activeSub
and bit32.band(activeSub.flags, PEEKING) == 0
and bit32.btest(activeSub.flags, bit32.bor(WATCHING, MUTABLE))
then
activeSub.flags = bit32.bor(activeSub.flags, PEEKING)
local results: { any } = { pcall(noYield(fn), ...) }
activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(PEEKING))
if not results[1] then
error(results[2])
end
return unpack(results, 2)
else
return fn(...)
end
end
--[[
Combines multiple updates into a single "commit" that runs effects and
computed signals after the provided callback finishes running.
@ -978,7 +945,6 @@ return {
effectScope = effectScope,
trigger = trigger,
untracked = untracked,
peek = peek,
batch = batch,
subscribe = subscribe,
listen = listen,

View file

@ -28,14 +28,13 @@ type Stack<T> = {
export type ReactiveFlags = number
local NONE = 0b0000000
local MUTABLE = 0b0000001
local WATCHING = 0b0000010
local RECURSED_CHECK = 0b0000100
local RECURSED = 0b0001000
local DIRTY = 0b0010000
local PENDING = 0b0100000
local PEEKING = 0b1000000
local NONE = 0b000000
local MUTABLE = 0b000001
local WATCHING = 0b000010
local RECURSED_CHECK = 0b000100
local RECURSED = 0b001000
local DIRTY = 0b010000
local PENDING = 0b100000
local ReactiveFlags = table.freeze({
None = NONE,
@ -45,7 +44,6 @@ local ReactiveFlags = table.freeze({
Recursed = RECURSED,
Dirty = DIRTY,
Pending = PENDING,
Peeking = PEEKING,
})
local update: (sub: ReactiveNode) -> boolean
@ -64,10 +62,6 @@ local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
end
local function link(dep: ReactiveNode, sub: ReactiveNode, version: number)
-- DEVIATION: Do not link signals to peeking subscribers
if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then
return
end
local prevDep = sub.depsTail
if prevDep and prevDep.dep == dep then
return

View file

@ -1,25 +1,51 @@
local test = require("../../tests/test")
local tests = {
"computed",
"effect",
"effectScope",
"gc",
"listen",
"mapped",
"observe",
"peek",
"signal",
"subscribe",
"topology",
"trigger",
"untracked",
}
test.describe("computed", function()
require("@self/computed.test")
end)
for _, name in tests do
test.describe(name, function()
(require)(`@self/{name}.test`)
end)
end
test.describe("effect", function()
require("@self/effect.test")
end)
test.describe("effectScope", function()
require("@self/effectScope.test")
end)
test.describe("gc", function()
require("@self/gc.test")
end)
test.describe("listen", function()
require("@self/listen.test")
end)
test.describe("mapped", function()
require("@self/mapped.test")
end)
test.describe("observe", function()
require("@self/observe.test")
end)
test.describe("signal", function()
require("@self/signal.test")
end)
test.describe("subscribe", function()
require("@self/subscribe.test")
end)
test.describe("topology", function()
require("@self/topology.test")
end)
test.describe("trigger", function()
require("@self/trigger.test")
end)
test.describe("untracked", function()
require("@self/untracked.test")
end)
return {}

View file

@ -1,146 +0,0 @@
local Charm = require("../src")
local test = require("../../../tests/test")
local signal = Charm.signal
local computed = Charm.computed
local effect = Charm.effect
local peek = Charm.peek
test("should not run detached", function()
local a, setA = signal(1)
local spyCalls = 0
effect(function()
a()
peek(function()
effect(function()
return function()
spyCalls += 1
end
end)
end)
end)
assert(spyCalls == 0, "should not be called yet")
setA(2)
assert(spyCalls == 1, "should be called once")
setA(3)
assert(spyCalls == 2, "should be called twice")
end)
test("should block tracking inside effects", function()
local a, setA = signal(1)
local b, setB = signal(2)
local spyCalls = 0
effect(function()
peek(function()
spyCalls += 1
return a() + b()
end)
end)
assert(spyCalls == 1, "should be called once")
setA(10)
setB(20)
assert(spyCalls == 1, "should be called once")
end)
test("should block tracking even when run inside effect run inside peek", function()
local s, setS = signal(1)
local spyCalls = 0
peek(function()
effect(function()
peek(function()
spyCalls += 1
return s()
end)
end)
end)
assert(spyCalls == 1, "should be called once")
setS(2)
assert(spyCalls == 1, "should be called once")
end)
test("should not cause signal assignments to throw", function()
local a, setA = signal(1)
local aChangedTime, setAChangedTime = signal(0)
local dispose = effect(function()
a()
peek(function()
setAChangedTime(aChangedTime() + 1)
end)
end)
setA(2)
assert(aChangedTime() == 2, "aChangedTime should be 2")
setA(3)
assert(aChangedTime() == 3, "aChangedTime should be 3")
dispose()
end)
test("should block tracking inside computed signals", function()
local a, setA = signal(1)
local b, setB = signal(2)
local spyCalls = 0
local c = computed(function()
return peek(function()
spyCalls += 1
return a() + b()
end)
end)
assert(spyCalls == 0, "should not be called yet")
assert(c() == 3, "c should be 3")
setA(10)
c()
setB(20)
c()
assert(spyCalls == 1, "should be called once")
assert(c() == 3, "c should be 3")
end)
test("should track outside peek", function()
local a, setA = signal(1)
local b, setB = signal(2)
local c, setC = signal(3)
local spyCalls = 0
effect(function()
a()
peek(c)
b()
spyCalls += 1
end)
assert(spyCalls == 1, "should be called once")
setA(10)
assert(spyCalls == 2, "should be called twice")
setB(20)
assert(spyCalls == 3, "should be called three times")
setC(30)
assert(spyCalls == 3, "should not run on c change")
end)
test("should stack peek", function()
local a, setA = signal(1)
local spyCalls = 0
effect(function()
peek(function()
peek(a)
return a()
end)
spyCalls += 1
end)
assert(spyCalls == 1, "should be called once")
setA(2)
assert(spyCalls == 1, "should not be called twice")
end)
return {}