From 1657e2cb747d8b369106e8ca2dc16e3a8528a427 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 2 Oct 2025 19:29:04 -0700 Subject: [PATCH] feat: add peek, make untracked escape scope --- packages/charm/src/__tests__/listen.spec.luau | 2 +- packages/charm/src/__tests__/peek.spec.luau | 147 ++++++++++++++++++ packages/charm/src/__tests__/spec.luau | 3 + .../charm/src/__tests__/subscribe.spec.luau | 2 +- .../charm/src/__tests__/untracked.spec.luau | 50 ++++-- packages/charm/src/core.luau | 4 +- packages/charm/src/index.d.ts | 4 +- packages/charm/src/init.luau | 2 + packages/charm/src/listen.luau | 4 +- packages/charm/src/peek.luau | 27 ++++ packages/charm/src/subscribe.luau | 4 +- packages/charm/src/untracked.luau | 27 ++-- 12 files changed, 248 insertions(+), 28 deletions(-) create mode 100644 packages/charm/src/__tests__/peek.spec.luau create mode 100644 packages/charm/src/peek.luau diff --git a/packages/charm/src/__tests__/listen.spec.luau b/packages/charm/src/__tests__/listen.spec.luau index d73bf23..b529cde 100644 --- a/packages/charm/src/__tests__/listen.spec.luau +++ b/packages/charm/src/__tests__/listen.spec.luau @@ -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) diff --git a/packages/charm/src/__tests__/peek.spec.luau b/packages/charm/src/__tests__/peek.spec.luau new file mode 100644 index 0000000..0c9cc5b --- /dev/null +++ b/packages/charm/src/__tests__/peek.spec.luau @@ -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 {} diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau index 46bd916..8273125 100644 --- a/packages/charm/src/__tests__/spec.luau +++ b/packages/charm/src/__tests__/spec.luau @@ -36,5 +36,8 @@ end) suite.describe("untracked", function() require("./untracked.spec") end) +suite.describe("peek", function() + require("./peek.spec") +end) return {} diff --git a/packages/charm/src/__tests__/subscribe.spec.luau b/packages/charm/src/__tests__/subscribe.spec.luau index 6d14a4d..3ebb003 100644 --- a/packages/charm/src/__tests__/subscribe.spec.luau +++ b/packages/charm/src/__tests__/subscribe.spec.luau @@ -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) diff --git a/packages/charm/src/__tests__/untracked.spec.luau b/packages/charm/src/__tests__/untracked.spec.luau index b32fd8c..f5c5f07 100644 --- a/packages/charm/src/__tests__/untracked.spec.luau +++ b/packages/charm/src/__tests__/untracked.spec.luau @@ -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() diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index 81296cc..dffba9a 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -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: () -> (), diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 533b052..a8cabeb 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -2,8 +2,8 @@ type Key = string | number | symbol; type Cleanup = () => void; export interface Atom { - (): T; (value: T | ((value: T) => T)): T; + (): T; } export type Selector = () => T; @@ -64,4 +64,6 @@ export function batched( export function untracked(callback: (...args: Args) => Result, ...args: Args): Result; +export function peek(callback: (...args: Args) => Result, ...args: Args): Result; + export function onCleanup(callback: Cleanup, failSilently?: boolean): void; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index e718af4..50350b5 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -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, diff --git a/packages/charm/src/listen.luau b/packages/charm/src/listen.luau index 4d3db95..90a6b4f 100644 --- a/packages/charm/src/listen.luau +++ b/packages/charm/src/listen.luau @@ -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(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 diff --git a/packages/charm/src/peek.luau b/packages/charm/src/peek.luau new file mode 100644 index 0000000..27c7e5a --- /dev/null +++ b/packages/charm/src/peek.luau @@ -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(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 diff --git a/packages/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau index c8ee09b..9c46c0a 100644 --- a/packages/charm/src/subscribe.luau +++ b/packages/charm/src/subscribe.luau @@ -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(getter: () -> T, listener: (state: T, lastState: T) if mounted then mounted = false else - untracked(listener, current, previous) + peek(listener, current, previous) end end) end diff --git a/packages/charm/src/untracked.luau b/packages/charm/src/untracked.luau index 9661fa3..6038fec 100644 --- a/packages/charm/src/untracked.luau +++ b/packages/charm/src/untracked.luau @@ -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(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