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) - [`observe(getter, callback)`](#observegetter-callback)
- [`subscribe(getter, callback)`](#subscribegetter-callback) - [`subscribe(getter, callback)`](#subscribegetter-callback)
- [`untracked(callback)`](#untrackedcallback) - [`untracked(callback)`](#untrackedcallback)
- [`peek(callback)`](#peekcallback)
- [`batch(callback)`](#batchcallback) - [`batch(callback)`](#batchcallback)
- [`mapped(getter, mapper)`](#mappedgetter-mapper) - [`mapped(getter, mapper)`](#mappedgetter-mapper)
- [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) - [`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)` ### `batch(callback)`
Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes. 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; type Cleanup = () => void;
export enum ReactiveFlags { export enum ReactiveFlags {
None = 0b0000000, None = 0b000000,
Mutable = 0b0000001, Mutable = 0b000001,
Watching = 0b0000010, Watching = 0b000010,
RecursedCheck = 0b0000100, RecursedCheck = 0b000100,
Recursed = 0b0001000, Recursed = 0b001000,
Dirty = 0b0010000, Dirty = 0b010000,
Pending = 0b0100000, Pending = 0b100000,
Peeking = 0b1000000,
} }
export interface ReactiveNode { 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 * current effect or scope from tracking inner effects created within the
* function. * 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 fn A function that may read signals or create effects.
* @param args Arguments to pass to the function. * @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; 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 * Combines multiple updates into a single "commit" that runs effects and
* computed signals after the provided function finishes running. * 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 -- Bitmask flags for tracking the state of reactive nodes. Flags are combined
-- and checked using bitwise operations for efficiency. -- and checked using bitwise operations for efficiency.
local NONE = 0b0000000 -- Default state local NONE = 0b000000 -- Default state
local MUTABLE = 0b0000001 -- Can be changed (signal or computed) local MUTABLE = 0b000001 -- Can be changed (signal or computed)
local WATCHING = 0b0000010 -- Watching for changes (effects) local WATCHING = 0b000010 -- Watching for changes (effects)
local RECURSED_CHECK = 0b0000100 -- Being checked for circular dependencies local RECURSED_CHECK = 0b000100 -- Being checked for circular dependencies
local DIRTY = 0b0010000 -- Value has changed and needs updating local DIRTY = 0b010000 -- Value has changed and needs updating
local PENDING = 0b0100000 -- Might be dirty, pending verification local PENDING = 0b100000 -- Might be dirty, pending verification
local PEEKING = 0b1000000 -- Blocking dependency tracking
-- Global version counter for dependency link deduplication. Incremented at the -- Global version counter for dependency link deduplication. Incremented at the
-- start of each effect/computed run to ensure links created in the same cycle -- 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 current effect or scope from tracking inner effects created within the
function. 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 fn A signal or function that accesses signals
@param ...args Arguments to pass to the function @param ...args Arguments to pass to the function
@ -724,39 +724,6 @@ local function untracked<Args..., Results...>(fn: (Args...) -> Results..., ...:
end end
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 Combines multiple updates into a single "commit" that runs effects and
computed signals after the provided callback finishes running. computed signals after the provided callback finishes running.
@ -978,7 +945,6 @@ return {
effectScope = effectScope, effectScope = effectScope,
trigger = trigger, trigger = trigger,
untracked = untracked, untracked = untracked,
peek = peek,
batch = batch, batch = batch,
subscribe = subscribe, subscribe = subscribe,
listen = listen, listen = listen,

View file

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

View file

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