feat: add peek, make untracked escape scope

This commit is contained in:
littensy 2025-10-02 19:29:04 -07:00
parent f5709c3112
commit 1657e2cb74
12 changed files with 248 additions and 28 deletions

View file

@ -28,7 +28,7 @@ test("should listen to selector", function()
assert(current == 2 and previous == 1, "should not run after unsub")
end)
test("should run in untracked", function()
test("should run in peek", function()
local runs = 0
local cleanups = 0
local a = atom(0)

View file

@ -0,0 +1,147 @@
local Charm = require("../")
local suite = require("../../../../test/suite")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local peek = Charm.peek
local test = suite.test
test("should not run detached", function()
local a = atom(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")
a(2)
assert(spyCalls == 1, "should be called once")
a(3)
assert(spyCalls == 2, "should be called twice")
end)
test("should block tracking inside effects", function()
local a = atom(1)
local b = atom(2)
local spyCalls = 0
effect(function()
peek(function()
spyCalls += 1
return a() + b()
end)
end)
assert(spyCalls == 1, "should be called once")
a(10)
b(20)
assert(spyCalls == 1, "should be called once")
end)
test("should block tracking even when run inside effect run inside peek", function()
local s = atom(1)
local spyCalls = 0
peek(function()
effect(function()
peek(function()
spyCalls += 1
return s()
end)
end)
end)
assert(spyCalls == 1, "should be called once")
s(2)
assert(spyCalls == 1, "should be called once")
end)
test("should not cause atom assignments throw", function()
local a = atom(1)
local aChangedTime = atom(0)
local dispose = effect(function()
a()
peek(function()
aChangedTime(aChangedTime() + 1)
end)
end)
a(2)
assert(aChangedTime() == 2, "aChangedTime should be 2")
a(3)
assert(aChangedTime() == 3, "aChangedTime should be 3")
dispose()
end)
test("should block tracking inside computed atoms", function()
local a = atom(1)
local b = atom(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")
a(10)
c()
b(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 = atom(1)
local b = atom(2)
local c = atom(3)
local spyCalls = 0
effect(function()
a()
peek(c)
b()
spyCalls += 1
end)
assert(spyCalls == 1, "should be called once")
a(10)
assert(spyCalls == 2, "should be called twice")
b(20)
assert(spyCalls == 3, "should be called three times")
c(30)
assert(spyCalls == 3, "should not run on c change")
end)
test("should stack peek", function()
local a = atom(1)
local spyCalls = 0
effect(function()
peek(function()
peek(a)
return a()
end)
spyCalls += 1
end)
assert(spyCalls == 1, "should be called once")
a(2)
assert(spyCalls == 1, "should not be called twice")
end)
return {}

View file

@ -36,5 +36,8 @@ end)
suite.describe("untracked", function()
require("./untracked.spec")
end)
suite.describe("peek", function()
require("./peek.spec")
end)
return {}

View file

@ -31,7 +31,7 @@ test("should subscribe to selector", function()
assert(current == 2 and previous == 1, "should not run after unsub")
end)
test("should run in untracked", function()
test("should run in peek", function()
local runs = 0
local cleanups = 0
local a = atom(0)

View file

@ -4,29 +4,59 @@ local suite = require("../../../../test/suite")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local effectScope = Charm.effectScope
local untracked = Charm.untracked
local test = suite.test
test("should not run detached", function()
test("should run detached from effect", function()
local a = atom(1)
local spyCalls = 0
local effects = 0
local innerEffects = {}
effect(function()
local stopEffect = effect(function()
a()
untracked(function()
effect(function()
local stopInnerEffect = effect(function()
effects += 1
return function()
spyCalls += 1
effects -= 1
end
end)
table.insert(innerEffects, stopInnerEffect)
end)
end)
assert(effects == 1, "should have one effect")
a(2)
assert(effects == 2, "should have two effects")
stopEffect()
assert(effects == 2, "should still have two effects")
for _, stopInnerEffect in innerEffects do
stopInnerEffect()
end
assert(effects == 0, "should have zero effects")
end)
test("should run detached from effect scope", function()
local effects = 0
local stopEffect
local stopScope = effectScope(function()
untracked(function()
stopEffect = effect(function()
effects += 1
return function()
effects -= 1
end
end)
end)
end)
assert(spyCalls == 0, "should not be called yet")
a(2)
assert(spyCalls == 1, "should be called once")
a(3)
assert(spyCalls == 2, "should be called twice")
assert(effects == 1, "should have one effect")
stopScope()
assert(effects == 1, "should still have one effect")
stopEffect()
assert(effects == 0, "should have zero effects")
end)
test("should block tracking inside effects", function()

View file

@ -23,11 +23,11 @@ local PENDING = 0b0100000
local QUEUED = 0b1000000
local UNTRACKED = 0b10000000
type EffectScope = ReactiveNode & {
export type EffectScope = ReactiveNode & {
cleanups: { () -> () },
}
type Effect = ReactiveNode & {
export type Effect = ReactiveNode & {
fn: (dispose: () -> ()) -> ...(() -> ())?,
cleanups: { () -> () },
cleanup: () -> (),

View file

@ -2,8 +2,8 @@ type Key = string | number | symbol;
type Cleanup = () => void;
export interface Atom<T> {
(): T;
(value: T | ((value: T) => T)): T;
(): T;
}
export type Selector<T> = () => T;
@ -64,4 +64,6 @@ export function batched<Args extends any[], Result>(
export function untracked<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
export function peek<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
export function onCleanup(callback: Cleanup, failSilently?: boolean): void;

View file

@ -4,6 +4,7 @@ local flags = require("@self/flags")
local listen = require("@self/listen")
local mapped = require("@self/mapped")
local observe = require("@self/observe")
local peek = require("@self/peek")
local subscribe = require("@self/subscribe")
local types = require("@self/types")
local untracked = require("@self/untracked")
@ -24,6 +25,7 @@ return {
listen = listen,
mapped = mapped,
observe = observe,
peek = peek,
subscribe = subscribe,
untracked = untracked,
deepFreeze = utils.deepFreeze,

View file

@ -1,5 +1,5 @@
local core = require("./core")
local untracked = require("./untracked")
local peek = require("./peek")
local computed = core.computed
local effect = core.effect
@ -19,7 +19,7 @@ local function listen<T>(getter: () -> T, listener: (state: T, previous: T?, dis
dispose = effect(function()
local previous = current
current = tracked()
untracked(listener, current, previous, dispose)
peek(listener, current, previous, dispose)
end)
if disposed then

View file

@ -0,0 +1,27 @@
local core = require("./core")
local UNTRACKED = core.ReactiveFlags.Untracked
local getCurrentSub = core.getCurrentSub
local function handlePeek(sub: core.ReactiveNode?, ok: boolean, ...)
if sub then
sub.flags = bit32.band(sub.flags, bit32.bnot(UNTRACKED))
end
if ok then
return ...
else
error((...))
end
end
local function peek<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
local sub = getCurrentSub()
if not sub or bit32.btest(sub.flags, UNTRACKED) then
return callback(...)
else
sub.flags = bit32.bor(sub.flags, UNTRACKED)
end
return handlePeek(sub, pcall(callback, ...))
end
return peek

View file

@ -1,5 +1,5 @@
local core = require("./core")
local untracked = require("./untracked")
local peek = require("./peek")
local computed = core.computed
local effect = core.effect
@ -18,7 +18,7 @@ local function subscribe<T>(getter: () -> T, listener: (state: T, lastState: T)
if mounted then
mounted = false
else
untracked(listener, current, previous)
peek(listener, current, previous)
end
end)
end

View file

@ -1,11 +1,20 @@
local core = require("./core")
local UNTRACKED = core.ReactiveFlags.Untracked
local setCurrentSub = core.setCurrentSub
local setCurrentScope = core.setCurrentScope
local getCurrentSub = core.getCurrentSub
local getCurrentScope = core.getCurrentScope
local function handleUntracked(sub: core.ReactiveNode?, ok: boolean, ...)
if sub then
sub.flags = bit32.band(sub.flags, bit32.bnot(UNTRACKED))
local function handleUntracked(
prevSub: core.ReactiveNode?,
prevScope: core.Effect | core.EffectScope?,
ok: boolean,
...
)
if prevSub then
setCurrentSub(prevSub)
elseif prevScope then
setCurrentScope(prevScope)
end
if ok then
return ...
@ -15,13 +24,13 @@ local function handleUntracked(sub: core.ReactiveNode?, ok: boolean, ...)
end
local function untracked<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
local sub = getCurrentSub()
if not sub or bit32.btest(sub.flags, UNTRACKED) then
return callback(...)
if getCurrentSub() then
return handleUntracked(setCurrentSub(nil), nil, pcall(callback, ...))
elseif getCurrentScope() then
return handleUntracked(nil, setCurrentScope(nil), pcall(callback, ...))
else
sub.flags = bit32.bor(sub.flags, UNTRACKED)
return callback(...)
end
return handleUntracked(sub, pcall(callback, ...))
end
return untracked