diff --git a/packages/charm-sync/src/__tests__/spec.luau b/packages/charm-sync/src/__tests__/spec.luau index 10f3bd9..84f7760 100644 --- a/packages/charm-sync/src/__tests__/spec.luau +++ b/packages/charm-sync/src/__tests__/spec.luau @@ -1,24 +1,23 @@ local suite = require("../../../../test/suite") -suite.describe("charm-sync", function() - if not game then - return suite.skip() - end - suite.describe("client", function() - require("./client.spec") - end) - suite.describe("server", function() - require("./server.spec") - end) - suite.describe("flatten", function() - require("./flatten.spec") - end) - suite.describe("patch", function() - require("./patch.spec") - end) - suite.describe("validate", function() - require("./validate.spec") - end) +suite.describe("client", function() + require("./client.spec") +end) + +suite.describe("server", function() + require("./server.spec") +end) + +suite.describe("flatten", function() + require("./flatten.spec") +end) + +suite.describe("patch", function() + require("./patch.spec") +end) + +suite.describe("validate", function() + require("./validate.spec") end) return {} diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index f96f6f6..fd449af 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -1,6 +1,8 @@ local Charm = require("../Charm") local validate = require("./validate") +local flags = Charm.flags + --- A special symbol that denotes the absence of a value. Used to represent --- deleted values in patches. local None = { __none = "__none" } @@ -63,7 +65,7 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serial patches = stringifySparseArray(patches) end - if serialize and Charm.strict.enabled then + if serialize and flags.strict then for key, value in next, patches do validate(value, key) end diff --git a/packages/charm/src/__tests__/core/gc.spec.luau b/packages/charm/src/__tests__/core/gc.spec.luau index 9914415..16bf679 100644 --- a/packages/charm/src/__tests__/core/gc.spec.luau +++ b/packages/charm/src/__tests__/core/gc.spec.luau @@ -23,13 +23,13 @@ local function gc() end end ---- In alien-core, a computed value unlinks from dependencies when its last +--- In alien-signals, a computed value unlinks from dependencies when its last --- parent scope is disposed. Calling a computed value outside of a scope links --- it to dependencies, but the dependencies hold strong references to the --- computed value, so it won't GC until called in a scope and disposed. --- --- This function releases a computed value by linking it to a scope and then ---- immediately closing it, causing alien-core to unlink the computed value +--- immediately closing it, causing alien-signals to unlink the computed value --- and allow it to be garbage collected. local function release(getter: () -> ()) effectScope(function() diff --git a/packages/charm/src/__tests__/peek.spec.luau b/packages/charm/src/__tests__/peek.spec.luau new file mode 100644 index 0000000..0e12b96 --- /dev/null +++ b/packages/charm/src/__tests__/peek.spec.luau @@ -0,0 +1,107 @@ +local Charm = require("../") +local suite = require("../../../../test/suite") + +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect +local onCleanup = Charm.onCleanup +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() + onCleanup(function() + spyCalls += 1 + 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) + +return {} diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau index 7d0d54a..4f63237 100644 --- a/packages/charm/src/__tests__/spec.luau +++ b/packages/charm/src/__tests__/spec.luau @@ -24,22 +24,20 @@ suite.describe("core", function() end) end) -suite.describe("charm", function() - suite.describe("listen", function() - require("./listen.spec") - end) - suite.describe("mapped", function() - require("./mapped.spec") - end) - suite.describe("observe", function() - require("./observe.spec") - end) - suite.describe("subscribe", function() - require("./subscribe.spec") - end) - suite.describe("untracked", function() - require("./untracked.spec") - end) +suite.describe("listen", function() + require("./listen.spec") +end) +suite.describe("mapped", function() + require("./mapped.spec") +end) +suite.describe("observe", function() + require("./observe.spec") +end) +suite.describe("subscribe", function() + require("./subscribe.spec") +end) +suite.describe("untracked", function() + require("./untracked.spec") end) return {} diff --git a/packages/charm/src/__tests__/untracked.spec.luau b/packages/charm/src/__tests__/untracked.spec.luau index 37a40aa..9d56084 100644 --- a/packages/charm/src/__tests__/untracked.spec.luau +++ b/packages/charm/src/__tests__/untracked.spec.luau @@ -5,8 +5,29 @@ local atom = Charm.atom local computed = Charm.computed local effect = Charm.effect local untracked = Charm.untracked +local onCleanup = Charm.onCleanup local test = suite.test +test("should run detached", function() + local a = atom(1) + local spyCalls = 0 + + effect(function() + a() + untracked(function() + onCleanup(function() + spyCalls += 1 + end, true) + end) + end) + + assert(spyCalls == 0, "should not be called") + a(2) + assert(spyCalls == 0, "rerun should not call cleanup") + a(3) + assert(spyCalls == 0, "rerun should not call cleanup") +end) + test("should block tracking inside effects", function() local a = atom(1) local b = atom(2) diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau index 75c8319..49ff50b 100644 --- a/packages/charm/src/core.luau +++ b/packages/charm/src/core.luau @@ -1,32 +1,4 @@ ---[[ - alien-signals 2.0.5 implementation adopted from - https://github.com/Nicell/alien-signals-luau - https://github.com/stackblitz/alien-signals - - MIT License - - Copyright (c) 2024-present Johnson Chu, Nicholas Winans - - 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. -]] - -local strict = require("./strict") +local config = require("./flags") local system = require("./system") local utils = require("./utils") @@ -103,15 +75,15 @@ local function setCurrentScope(scope: Effect | EffectScope?) end local function setCurrentTracking(tracking: boolean): boolean - local prevTracking = activeSubTracking + local prevSubTracking = activeSubTracking activeSubTracking = tracking - return prevTracking + return prevSubTracking end local function runCleanups(effect: EffectScope) for _, cleanup in effect.cleanups do local prev = setCurrentSub(nil) - local success, result: unknown = (if strict.enabled then noYield else pcall)(cleanup) + local success, result: unknown = (if config.strict then noYield else pcall)(cleanup) setCurrentSub(prev) if not success then error(result) @@ -136,7 +108,7 @@ local function run(effect: Effect | EffectScope, flags: ReactiveFlags): () local prev = setCurrentSub(effect) local prevTracking = setCurrentTracking(true) startTracking(effect) - local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)((effect :: Effect).fn) + local success, result: (() -> ())? = (if config.strict then noYield else pcall)((effect :: Effect).fn) setCurrentSub(prev) endTracking(effect) setCurrentTracking(prevTracking) @@ -194,7 +166,7 @@ local function updateComputed(computed: Computed): boolean local prevTracking = setCurrentTracking(true) startTracking(computed) local oldValue = computed.value - local success, result = (if strict.enabled then noYield else pcall)(computed.getter, oldValue) + local success, result = (if config.strict then noYield else pcall)(computed.getter, oldValue) setCurrentSub(prevSub) endTracking(computed) setCurrentTracking(prevTracking) @@ -262,7 +234,7 @@ local function atomGetterSetter(atom: Atom): (...((T) -> T) | T) -> T local newValue = ... if type(newValue) == "function" then local prev = setCurrentSub(nil) - local success, result = (if strict.enabled then noYield else pcall)(newValue, oldValue) + local success, result = (if config.strict then noYield else pcall)(newValue, oldValue) setCurrentSub(prev) if not success then error(result) @@ -380,7 +352,7 @@ local function effect(fn: () -> ...(() -> ())?): () -> () end local prev = setCurrentSub(self) local prevTracking = setCurrentTracking(true) - local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn) + local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn) setCurrentSub(prev) setCurrentTracking(prevTracking) if not success then @@ -401,7 +373,7 @@ local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> end local prevSub = setCurrentSub(nil) local prevScope = setCurrentScope(self) - local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn) + local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn) setCurrentScope(prevScope) setCurrentSub(prevSub) if not success then diff --git a/packages/charm/src/strict.luau b/packages/charm/src/flags.luau similarity index 72% rename from packages/charm/src/strict.luau rename to packages/charm/src/flags.luau index 1955aa1..961dd6f 100644 --- a/packages/charm/src/strict.luau +++ b/packages/charm/src/flags.luau @@ -4,5 +4,5 @@ end return { -- Enable strict mode in O2 - enabled = inlineTest() == "inlineTest", + strict = inlineTest() == "inlineTest", } diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index cb0ff5c..3193856 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -1 +1,68 @@ -export {}; +type Key = string | number | symbol; +type Cleanup = () => void; + +export interface Atom { + (): T; + (value: T | ((value: T) => T)): T; +} + +export type Selector = () => T; + +export type Equals = (current: T, incoming: T) => boolean; + +export const flags: { + strict: boolean; +}; + +export function atom(value: T, equals?: Equals): Atom; +// Overload for undefined initial value +export function atom(): Atom; + +export function computed(getter: (previousValue?: T) => T, equals?: Equals): () => T; + +export function effect(callback: () => Cleanup | void): Cleanup; + +export function effectScope(callback: () => Cleanup | void, detached?: boolean): Cleanup; + +export function listen( + callback: () => T, + listener: (value: T, previousValue: T | undefined, dispose: Cleanup) => void, +): Cleanup; + +export function subscribe(callback: () => T, listener: (value: T, previousValue: T) => void): Cleanup; + +export function mapped( + callback: () => Map | ReadonlyMap, + mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, +): () => ReadonlyMap; +// Overload for arrays +export function mapped( + callback: () => readonly VI[], + mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, +): () => K extends number ? readonly VO[] : { readonly [P in K]: VO }; +// Overload for objects +export function mapped( + callback: () => { readonly [K in KI]: VI }, + mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, +): () => { readonly [K in KO]: VO }; + +export function observe( + callback: () => Map | ReadonlyMap | readonly V[], + observer: (value: V, key: K) => Cleanup | void, +): Cleanup; +// Overload for objects +export function observe( + callback: () => { readonly [K in Key]: V }, + observer: (value: V, key: K) => Cleanup | void, +): Cleanup; + +export function onCleanup(callback: Cleanup, failSilently?: boolean): void; + +export function batched( + callback: (...args: Args) => Result, + ...args: Args +): (...args: Args) => Result; + +export function peek(callback: (...args: Args) => Result, ...args: Args): Result; + +export function untracked(callback: (...args: Args) => Result, ...args: Args): Result; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index e583ae6..7897bd9 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,9 +1,10 @@ local batched = require("@self/batched") local core = require("@self/core") +local flags = require("@self/flags") local listen = require("@self/listen") local mapped = require("@self/mapped") local observe = require("@self/observe") -local strict = require("@self/strict") +local peek = require("@self/peek") local subscribe = require("@self/subscribe") local types = require("@self/types") local untracked = require("@self/untracked") @@ -11,24 +12,19 @@ local untracked = require("@self/untracked") export type Atom = types.Atom export type Selector = types.Selector -local atom = core.atom -local computed = core.computed -local effect = core.effect -local effectScope = core.effectScope -local onCleanup = core.onCleanup - return { core = core, - strict = strict, - atom = atom, + flags = flags, + atom = core.atom, + computed = core.computed, + effect = core.effect, + effectScope = core.effectScope, + onCleanup = core.onCleanup, batched = batched, - computed = computed, - effect = effect, - effectScope = effectScope, listen = listen, mapped = mapped, observe = observe, - onCleanup = onCleanup, + peek = peek, subscribe = subscribe, untracked = untracked, } diff --git a/packages/charm/src/listen.luau b/packages/charm/src/listen.luau index 865e704..ed15d77 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(selector: () -> T, listener: (state: T, previous: T?, d 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..a8bc08f --- /dev/null +++ b/packages/charm/src/peek.luau @@ -0,0 +1,18 @@ +local core = require("./core") + +local setCurrentTracking = core.setCurrentTracking + +local function handlePeek(prevTracking: boolean, ok: boolean, ...) + setCurrentTracking(prevTracking) + if ok then + return ... + else + error((...)) + end +end + +local function peek(callback: (Args...) -> Results..., ...: Args...): Results... + return handlePeek(setCurrentTracking(false), pcall(callback, ...)) +end + +return peek diff --git a/packages/charm/src/subscribe.luau b/packages/charm/src/subscribe.luau index 1ceca66..bd2b8c1 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(selector: () -> 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 b766aae..80c2671 100644 --- a/packages/charm/src/untracked.luau +++ b/packages/charm/src/untracked.luau @@ -1,9 +1,9 @@ local core = require("./core") -local setCurrentTracking = core.setCurrentTracking +local setCurrentSub = core.setCurrentSub -local function handleUntracked(prevTracking: boolean, ok: boolean, ...) - setCurrentTracking(prevTracking) +local function handleUntracked(prevSub: core.ReactiveNode?, ok: boolean, ...) + setCurrentSub(prevSub) if ok then return ... else @@ -12,7 +12,7 @@ local function handleUntracked(prevTracking: boolean, ok: boolean, ...) end local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... - return handleUntracked(setCurrentTracking(false), pcall(callback, ...)) + return handleUntracked(setCurrentSub(nil), pcall(callback, ...)) end return untracked diff --git a/packages/charm/test/default.project.json b/packages/charm/test/default.project.json deleted file mode 100644 index 4cd33dd..0000000 --- a/packages/charm/test/default.project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "charm", - "tree": { - "$path": "src" - } -} \ No newline at end of file diff --git a/packages/charm/test/src/batched.luau b/packages/charm/test/src/batched.luau deleted file mode 100644 index 685365a..0000000 --- a/packages/charm/test/src/batched.luau +++ /dev/null @@ -1,20 +0,0 @@ -local core = require("./core") - -local startBatch = core.startBatch -local endBatch = core.endBatch - -local function handleBatched(ok: boolean, ...) - endBatch() - if ok then - return ... - else - error((...)) - end -end - -local function batched(callback: (Args...) -> Results..., ...: Args...): Results... - startBatch() - return handleBatched(pcall(callback, ...)) -end - -return batched diff --git a/packages/charm/test/src/core.luau b/packages/charm/test/src/core.luau deleted file mode 100644 index 75c8319..0000000 --- a/packages/charm/test/src/core.luau +++ /dev/null @@ -1,445 +0,0 @@ ---[[ - alien-signals 2.0.5 implementation adopted from - https://github.com/Nicell/alien-signals-luau - https://github.com/stackblitz/alien-signals - - MIT License - - Copyright (c) 2024-present Johnson Chu, Nicholas Winans - - 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. -]] - -local strict = require("./strict") -local system = require("./system") -local utils = require("./utils") - -export type ReactiveFlags = system.ReactiveFlags -export type ReactiveNode = system.ReactiveNode -export type Link = system.Link - -local ReactiveFlags = system.ReactiveFlags -local createReactiveSystem = system.createReactiveSystem -local noYield = utils.noYield - -local NONE = 0b0000000 -local MUTABLE = 0b0000001 -local WATCHING = 0b0000010 -local DIRTY = 0b0010000 -local PENDING = 0b0100000 -local QUEUED = 0b1000000 - -type EffectScope = ReactiveNode & { - cleanups: { () -> () }, -} - -type Effect = ReactiveNode & { - fn: () -> ...(() -> ())?, - cleanups: { () -> () }, -} - -type Computed = ReactiveNode & { - value: T?, - getter: (previousValue: T?) -> T, - equals: ((T, T) -> boolean)?, -} - -type Atom = ReactiveNode & { - previousValue: T, - value: T, - equals: ((T, T) -> boolean)?, -} - -local link: (dep: ReactiveNode, sub: ReactiveNode) -> () -local unlink: (link: Link, sub: ReactiveNode?) -> Link? -local propagate: (link: Link) -> () -local checkDirty: (link: Link, sub: ReactiveNode) -> boolean -local endTracking: (sub: ReactiveNode) -> () -local startTracking: (sub: ReactiveNode) -> () -local shallowPropagate: (link: Link) -> () - -local batchDepth = 0 -local queuedEffects: { Effect | EffectScope? } = {} -local queuedEffectsLength = 0 -local notifyIndex = 0 -local activeSubTracking = true -local activeSub: ReactiveNode? -local activeScope: Effect | EffectScope? - -local function getCurrentSub(): ReactiveNode? - return activeSub -end - -local function setCurrentSub(sub: ReactiveNode?) - local prevSub = activeSub - activeSub = sub - return prevSub -end - -local function getCurrentScope(): Effect | EffectScope? - return activeScope -end - -local function setCurrentScope(scope: Effect | EffectScope?) - local prevScope = activeScope - activeScope = scope - return prevScope -end - -local function setCurrentTracking(tracking: boolean): boolean - local prevTracking = activeSubTracking - activeSubTracking = tracking - return prevTracking -end - -local function runCleanups(effect: EffectScope) - for _, cleanup in effect.cleanups do - local prev = setCurrentSub(nil) - local success, result: unknown = (if strict.enabled then noYield else pcall)(cleanup) - setCurrentSub(prev) - if not success then - error(result) - end - end - table.clear(effect.cleanups) -end - -local function onCleanup(cleanup: () -> (), failSilently: boolean?) - if activeSub then - table.insert((activeSub :: Effect).cleanups, cleanup) - elseif activeScope then - table.insert(activeScope.cleanups, cleanup) - elseif not failSilently then - warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) - end -end - -local function run(effect: Effect | EffectScope, flags: ReactiveFlags): () - if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then - runCleanups(effect) - local prev = setCurrentSub(effect) - local prevTracking = setCurrentTracking(true) - startTracking(effect) - local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)((effect :: Effect).fn) - setCurrentSub(prev) - endTracking(effect) - setCurrentTracking(prevTracking) - if not success then - error(result) - elseif result then - table.insert(effect.cleanups, result) - end - return - elseif bit32.btest(flags, PENDING) then - (effect :: Effect).flags = bit32.band(flags, bit32.bnot(PENDING)) - end - local link = effect.deps - while link do - local dep = link.dep - local depFlags = dep.flags - if bit32.btest(depFlags, QUEUED) then - depFlags = bit32.band(depFlags, bit32.bnot(QUEUED)) - dep.flags = depFlags - run(dep :: Effect, depFlags) - end - link = link.nextDep - end -end - -local function flush(): () - while notifyIndex < queuedEffectsLength do - notifyIndex += 1 - local effect = queuedEffects[notifyIndex] :: Effect - queuedEffects[notifyIndex] = nil - effect.flags = bit32.band(effect.flags, bit32.bnot(QUEUED)) - run(effect, effect.flags) - end - notifyIndex = 0 - queuedEffectsLength = 0 -end - -local function getBatchDepth(): number - return batchDepth -end - -local function startBatch() - batchDepth += 1 -end - -local function endBatch() - batchDepth -= 1 - if batchDepth == 0 then - flush() - end -end - -local function updateComputed(computed: Computed): boolean - local prevSub = setCurrentSub(computed) - local prevTracking = setCurrentTracking(true) - startTracking(computed) - local oldValue = computed.value - local success, result = (if strict.enabled then noYield else pcall)(computed.getter, oldValue) - setCurrentSub(prevSub) - endTracking(computed) - setCurrentTracking(prevTracking) - if not success then - error(result) - end - local equals = computed.equals - if if equals then not equals(oldValue, result) else oldValue ~= result then - computed.value = result - return true - else - return false - end -end - -local function updateAtom(atom: Atom, value: any): boolean - atom.flags = MUTABLE - if atom.previousValue ~= value then - atom.previousValue = value - return true - else - return false - end -end - -local function readAtom(atom: Atom): T - local value = atom.value - if bit32.btest(atom.flags, DIRTY) then - if updateAtom(atom, value) then - local subs = atom.subs - if subs then - shallowPropagate(subs) - end - end - end - if activeSub and activeSubTracking then - link(atom, activeSub) - end - return value -end - -local function writeAtom(atom: Atom, oldValue: T, newValue: T): T - local equals = atom.equals - if if equals then not equals(oldValue, newValue :: T) else oldValue ~= newValue then - atom.value = newValue - atom.flags = bit32.bor(MUTABLE, DIRTY) - local subs = atom.subs - if subs then - propagate(subs) - if batchDepth == 0 then - flush() - end - end - return newValue - end - return oldValue -end - -local function atomGetterSetter(atom: Atom): (...((T) -> T) | T) -> T - return function(...) - if select("#", ...) == 0 then - return readAtom(atom) - end - local oldValue = atom.value - local newValue = ... - if type(newValue) == "function" then - local prev = setCurrentSub(nil) - local success, result = (if strict.enabled then noYield else pcall)(newValue, oldValue) - setCurrentSub(prev) - if not success then - error(result) - end - newValue = result - end - return writeAtom(atom, oldValue, newValue :: T) - end -end - -local function computedGetter(computed: Computed): () -> T - return function() - local flags = computed.flags - if - bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(computed.deps :: Link, computed)) - then - if updateComputed(computed) then - local subs = computed.subs - if subs then - shallowPropagate(subs) - end - end - elseif bit32.btest(flags, PENDING) then - computed.flags = bit32.band(flags, bit32.bnot(PENDING)) - end - if activeSub then - if activeSubTracking then - link(computed, activeSub) - end - elseif activeScope then - link(computed, activeScope) - end - return computed.value :: T - end -end - -local function effectStop(self: Effect | EffectScope): () -> () - return function() - local dep = self.deps - while dep do - dep = unlink(dep, self) - end - local sub = self.subs - if sub then - unlink(sub) - end - (self :: Effect).flags = NONE - runCleanups(self) - end -end - -local function update(atom: Atom | Computed): boolean - if (atom :: Computed).getter then - return updateComputed(atom :: Computed) - else - return updateAtom(atom :: Atom, atom.value) - end -end - -local function notify(effect: Effect | EffectScope) - local flags = effect.flags - if not bit32.btest(flags, QUEUED) then - (effect :: Effect).flags = bit32.bor(flags, QUEUED) - local subs = effect.subs - if subs then - notify(subs.sub :: Effect) - else - queuedEffectsLength += 1 - queuedEffects[queuedEffectsLength] = effect - end - end -end - -local function unwatched(node: Atom | Computed | Effect | EffectScope) - if (node :: Computed).getter then - local toRemove = node.deps - if toRemove then - (node :: Computed).flags = bit32.bor(MUTABLE, DIRTY) - repeat - toRemove = unlink(toRemove, node) - until not toRemove - end - elseif (node :: Effect).cleanups then - effectStop(node :: Effect)() - end -end - -local function atom(initialValue: T, equals: ((T, T) -> boolean)?): (...((T) -> T) | T) -> T - return atomGetterSetter({ - previousValue = initialValue, - value = initialValue, - flags = MUTABLE, - equals = equals, - }) -end - -local function computed(getter: (previousValue: T?) -> T, equals: ((T, T) -> boolean)?): () -> T - return computedGetter({ - flags = bit32.bor(MUTABLE, DIRTY), - getter = getter, - equals = equals, - }) -end - -local function effect(fn: () -> ...(() -> ())?): () -> () - local self: Effect = { - fn = fn, - flags = WATCHING, - cleanups = {}, - } - if activeSub then - link(self, activeSub) - elseif activeScope then - link(self, activeScope) - end - local prev = setCurrentSub(self) - local prevTracking = setCurrentTracking(true) - local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn) - setCurrentSub(prev) - setCurrentTracking(prevTracking) - if not success then - error(result) - elseif result then - table.insert(self.cleanups, result) - end - return effectStop(self) -end - -local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () - local self: EffectScope = { - flags = NONE, - cleanups = {}, - } - if activeScope and not detached then - link(self, activeScope) - end - local prevSub = setCurrentSub(nil) - local prevScope = setCurrentScope(self) - local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn) - setCurrentScope(prevScope) - setCurrentSub(prevSub) - if not success then - error(result) - elseif result then - table.insert(self.cleanups, result) - end - return effectStop(self) -end - -local reactiveSystem = createReactiveSystem({ - update = update :: (ReactiveNode) -> boolean, - notify = notify :: (ReactiveNode) -> (), - unwatched = unwatched :: (ReactiveNode) -> (), -}) - -link = reactiveSystem.link -unlink = reactiveSystem.unlink -propagate = reactiveSystem.propagate -checkDirty = reactiveSystem.checkDirty -endTracking = reactiveSystem.endTracking -startTracking = reactiveSystem.startTracking -shallowPropagate = reactiveSystem.shallowPropagate - -return { - ReactiveFlags = ReactiveFlags, - createReactiveSystem = createReactiveSystem, - getCurrentSub = getCurrentSub, - setCurrentSub = setCurrentSub, - getCurrentScope = getCurrentScope, - setCurrentScope = setCurrentScope, - setCurrentTracking = setCurrentTracking, - onCleanup = onCleanup, - getBatchDepth = getBatchDepth, - startBatch = startBatch, - endBatch = endBatch, - atom = atom, - computed = computed, - effect = effect, - effectScope = effectScope, -} diff --git a/packages/charm/test/src/init.luau b/packages/charm/test/src/init.luau deleted file mode 100644 index e583ae6..0000000 --- a/packages/charm/test/src/init.luau +++ /dev/null @@ -1,34 +0,0 @@ -local batched = require("@self/batched") -local core = require("@self/core") -local listen = require("@self/listen") -local mapped = require("@self/mapped") -local observe = require("@self/observe") -local strict = require("@self/strict") -local subscribe = require("@self/subscribe") -local types = require("@self/types") -local untracked = require("@self/untracked") - -export type Atom = types.Atom -export type Selector = types.Selector - -local atom = core.atom -local computed = core.computed -local effect = core.effect -local effectScope = core.effectScope -local onCleanup = core.onCleanup - -return { - core = core, - strict = strict, - atom = atom, - batched = batched, - computed = computed, - effect = effect, - effectScope = effectScope, - listen = listen, - mapped = mapped, - observe = observe, - onCleanup = onCleanup, - subscribe = subscribe, - untracked = untracked, -} diff --git a/packages/charm/test/src/listen.luau b/packages/charm/test/src/listen.luau deleted file mode 100644 index 865e704..0000000 --- a/packages/charm/test/src/listen.luau +++ /dev/null @@ -1,32 +0,0 @@ -local core = require("./core") -local untracked = require("./untracked") - -local computed = core.computed -local effect = core.effect - -local function listen(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () - local tracked = computed(function() - return selector() - end) - - local current: T - local disposed = false - - local function dispose() - disposed = true - end - - dispose = effect(function() - local previous = current - current = tracked() - untracked(listener, current, previous, dispose) - end) - - if disposed then - dispose() - end - - return dispose -end - -return listen diff --git a/packages/charm/test/src/mapped.luau b/packages/charm/test/src/mapped.luau deleted file mode 100644 index f6289be..0000000 --- a/packages/charm/test/src/mapped.luau +++ /dev/null @@ -1,59 +0,0 @@ -local core = require("./core") - -local computed = core.computed -local setCurrentSub = core.setCurrentSub - -type Mapped = - & ((selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO }) - & ((selector: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }) - -local function mapped(selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } - local currentData: { [KI]: VI } = {} - local keyCache: { [KI]: KO } = {} - - local function map(value: VI, key: KI): (VO, KO) - local currentSub = setCurrentSub(nil) - local success, valueOut, keyOut = pcall(mapper, value, key) - setCurrentSub(currentSub) - - if not success then - error(`mapped() transform function failed: {valueOut}`) - end - - return valueOut, if keyOut == nil then key else keyOut :: any - end - - return computed(function(previousOutput) - local currentOutput: { [KO]: VO } = previousOutput or {} - local previousData = currentData - currentData = selector() - - for key in previousData do - if currentData[key] ~= nil then - continue - end - local cachedKey = keyCache[key] - if cachedKey then - currentOutput = if currentOutput == previousOutput then table.clone(currentOutput) else currentOutput - currentOutput[cachedKey] = nil - keyCache[key] = nil - end - end - - for key, value in currentData do - if value == previousData[key] then - continue - end - local valueOut, keyOut = map(value, key) - if currentOutput[keyOut] ~= valueOut then - currentOutput = if currentOutput == previousOutput then table.clone(currentOutput) else currentOutput - currentOutput[keyOut] = valueOut - keyCache[key] = keyOut - end - end - - return currentOutput - end) -end - -return mapped :: Mapped diff --git a/packages/charm/test/src/observe.luau b/packages/charm/test/src/observe.luau deleted file mode 100644 index 7272584..0000000 --- a/packages/charm/test/src/observe.luau +++ /dev/null @@ -1,52 +0,0 @@ -local core = require("./core") - -local effect = core.effect -local effectScope = core.effectScope -local onCleanup = core.onCleanup - -local function observe(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> () - local scopes: { [K]: () -> () } = {} - local currentItems: { [K]: V } = {} - - local function updateScopes(currentItems: { [K]: V }, previousItems: { [K]: V }) - for key in previousItems do - local dispose = scopes[key] - if currentItems[key] == nil and dispose then - dispose() - scopes[key] = nil - end - end - - for key, value in currentItems do - if previousItems[key] == nil then - scopes[key] = effectScope(function() - return observer(value, key) - end, true) - end - end - end - - local function disposeScopes() - for _, dispose in scopes do - dispose() - end - table.clear(scopes) - end - - local disposeEffect = effect(function() - local previousItems = currentItems - currentItems = selector() - if currentItems ~= previousItems then - updateScopes(currentItems, previousItems) - end - end) - - onCleanup(disposeScopes, true) - - return function() - disposeEffect() - disposeScopes() - end -end - -return observe diff --git a/packages/charm/test/src/strict.luau b/packages/charm/test/src/strict.luau deleted file mode 100644 index 1955aa1..0000000 --- a/packages/charm/test/src/strict.luau +++ /dev/null @@ -1,8 +0,0 @@ -local function inlineTest(): string - return debug.info(1, "n") -end - -return { - -- Enable strict mode in O2 - enabled = inlineTest() == "inlineTest", -} diff --git a/packages/charm/test/src/subscribe.luau b/packages/charm/test/src/subscribe.luau deleted file mode 100644 index 1ceca66..0000000 --- a/packages/charm/test/src/subscribe.luau +++ /dev/null @@ -1,26 +0,0 @@ -local core = require("./core") -local untracked = require("./untracked") - -local computed = core.computed -local effect = core.effect - -local function subscribe(selector: () -> T, listener: (state: T, lastState: T) -> ()): () -> () - local tracked = computed(function() - return selector() - end) - - local current: T - local mounted = true - - return effect(function() - local previous = current - current = tracked() - if mounted then - mounted = false - else - untracked(listener, current, previous) - end - end) -end - -return subscribe diff --git a/packages/charm/test/src/system.luau b/packages/charm/test/src/system.luau deleted file mode 100644 index d3d5692..0000000 --- a/packages/charm/test/src/system.luau +++ /dev/null @@ -1,357 +0,0 @@ ---[[ - alien-signals 2.0.1 implementation adopted from - https://github.com/Nicell/alien-signals-luau - https://github.com/stackblitz/alien-signals - - MIT License - - Copyright (c) 2024-present Johnson Chu, Nicholas Winans - - 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. -]] - -export type ReactiveNode = { - deps: Link?, - depsTail: Link?, - subs: Link?, - subsTail: Link?, - flags: ReactiveFlags, -} - -export type Link = { - dep: ReactiveNode, - sub: ReactiveNode, - prevSub: Link?, - nextSub: Link?, - prevDep: Link?, - nextDep: Link?, -} - -type Stack = { - value: T, - prev: Stack?, -} - -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 ReactiveFlags = { - None = NONE, - Mutable = MUTABLE, - Watching = WATCHING, - RecursedCheck = RECURSED_CHECK, - Recursed = RECURSED, - Dirty = DIRTY, - Pending = PENDING, -} - -local function createReactiveSystem(props: { - update: (sub: ReactiveNode) -> boolean, - notify: (sub: ReactiveNode) -> (), - unwatched: (sub: ReactiveNode) -> (), -}) - local update = props.update - local notify = props.notify - local unwatched = props.unwatched - - local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean - local depsTail = sub.depsTail - if depsTail then - local link = sub.deps :: Link - repeat - if link == checkLink then - return true - end - if link == depsTail then - break - end - link = link.nextDep :: Link - until not link - end - return false - end - - local function link(dep: ReactiveNode, sub: ReactiveNode): () - local prevDep = sub.depsTail - if prevDep and prevDep.dep == dep then - return - end - local nextDep: Link? - local recursedCheck = bit32.btest(sub.flags, RECURSED_CHECK) - if recursedCheck then - nextDep = if prevDep then prevDep.nextDep else sub.deps - if nextDep and nextDep.dep == dep then - sub.depsTail = nextDep - return - end - end - local prevSub = dep.subsTail - if prevSub and prevSub.sub == sub and (not recursedCheck or isValidLink(prevSub, sub)) then - return - end - local newLink: Link = { - dep = dep, - sub = sub, - prevDep = prevDep, - nextDep = nextDep, - prevSub = prevSub, - } - sub.depsTail = newLink - dep.subsTail = newLink - if nextDep then - nextDep.prevDep = newLink - end - if prevDep then - prevDep.nextDep = newLink - else - sub.deps = newLink - end - if prevSub then - prevSub.nextSub = newLink - else - dep.subs = newLink - end - end - - local function unlink(link: Link, sub: ReactiveNode?): Link? - local sub = sub or link.sub - local dep = link.dep - local prevDep = link.prevDep - local nextDep = link.nextDep - local nextSub = link.nextSub - local prevSub = link.prevSub - if nextDep then - nextDep.prevDep = prevDep - else - sub.depsTail = prevDep - end - if prevDep then - prevDep.nextDep = nextDep - else - sub.deps = nextDep - end - if nextSub then - nextSub.prevSub = prevSub - else - dep.subsTail = prevSub - end - if prevSub then - prevSub.nextSub = nextSub - else - dep.subs = nextSub - if not nextSub then - unwatched(dep) - end - end - return nextDep - end - - local function propagate(link: Link): () - local next = link.nextSub - local stack: Stack? - - repeat - local sub = link.sub - - local flags = sub.flags - - if bit32.btest(flags, bit32.bor(MUTABLE, WATCHING)) then - if not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) then - sub.flags = bit32.bor(flags, PENDING) - elseif not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED)) then - flags = NONE - elseif not bit32.btest(flags, RECURSED_CHECK) then - sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING) - elseif not bit32.btest(flags, bit32.bor(DIRTY, PENDING)) and isValidLink(link, sub) then - sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING)) - flags = bit32.band(flags, MUTABLE) - else - -- selene: allow(if_same_then_else) - flags = NONE - end - - if bit32.btest(flags, WATCHING) then - notify(sub) - end - - if bit32.btest(flags, MUTABLE) then - local subSubs = sub.subs - if subSubs then - link = subSubs - if subSubs.nextSub then - stack = { value = next, prev = stack } - next = link.nextSub - end - continue - end - end - end - - link = next :: Link - - if link then - next = link.nextSub - continue - end - - local continueTop = false - - while stack do - link = stack.value :: Link - stack = stack.prev - if link then - next = link.nextSub - continueTop = true - break - end - end - - if continueTop then - continue - end - - break - until false - end - - local function startTracking(sub: ReactiveNode): () - sub.depsTail = nil - sub.flags = bit32.bor(bit32.band(sub.flags, bit32.bnot(bit32.bor(RECURSED, DIRTY, PENDING))), RECURSED_CHECK) - end - - local function endTracking(sub: ReactiveNode): () - local depsTail = sub.depsTail - local toRemove = if depsTail then depsTail.nextDep else sub.deps - while toRemove do - toRemove = unlink(toRemove, sub) - end - sub.flags = bit32.band(sub.flags, bit32.bnot(RECURSED_CHECK)) - end - - local function shallowPropagate(link: Link): () - repeat - local sub = link.sub - local subFlags = sub.flags - if bit32.band(subFlags, bit32.bor(PENDING, DIRTY)) == PENDING then - sub.flags = bit32.bor(subFlags, DIRTY) - if bit32.btest(subFlags, WATCHING) then - notify(sub) - end - end - link = link.nextSub :: Link - until not link - end - - local function checkDirty(link: Link, sub: ReactiveNode): boolean - local stack: Stack? - local checkDepth = 0 - - repeat - local dep = link.dep - local depFlags = dep.flags - - local dirty = false - - if bit32.btest(sub.flags, DIRTY) then - dirty = true - elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then - if update(dep) then - local subs = dep.subs :: Link - if subs.nextSub then - shallowPropagate(subs) - end - dirty = true - end - elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then - if link.nextSub or link.prevSub then - stack = { value = link, prev = stack } - end - link = dep.deps :: Link - sub = dep - checkDepth += 1 - continue - end - - if not dirty and link.nextDep then - link = link.nextDep - continue - end - - local continueTop = false - - while checkDepth ~= 0 do - checkDepth -= 1 - local firstSub = sub.subs :: Link - local hasMultipleSubs = firstSub.nextSub ~= nil - if hasMultipleSubs then - link = (stack :: Stack).value - stack = (stack :: Stack).prev - else - link = firstSub - end - if dirty then - if update(sub) then - if hasMultipleSubs then - shallowPropagate(firstSub) - end - sub = link.sub - continue - end - else - sub.flags = bit32.band(sub.flags, bit32.bnot(PENDING)) - end - sub = link.sub - if link.nextDep then - link = link.nextDep - continueTop = true - break - end - dirty = false - end - - if continueTop then - continue - end - - return dirty - until false - end - - return { - link = link, - unlink = unlink, - propagate = propagate, - checkDirty = checkDirty, - endTracking = endTracking, - startTracking = startTracking, - shallowPropagate = shallowPropagate, - } -end - -return { - ReactiveFlags = ReactiveFlags, - createReactiveSystem = createReactiveSystem, -} diff --git a/packages/charm/test/src/types.luau b/packages/charm/test/src/types.luau deleted file mode 100644 index d5c0314..0000000 --- a/packages/charm/test/src/types.luau +++ /dev/null @@ -1,5 +0,0 @@ -type Update = ((previous: T) -> T) | T -export type Atom = (...Update) -> T -export type Selector = () -> T - -return {} diff --git a/packages/charm/test/src/untracked.luau b/packages/charm/test/src/untracked.luau deleted file mode 100644 index b766aae..0000000 --- a/packages/charm/test/src/untracked.luau +++ /dev/null @@ -1,18 +0,0 @@ -local core = require("./core") - -local setCurrentTracking = core.setCurrentTracking - -local function handleUntracked(prevTracking: boolean, ok: boolean, ...) - setCurrentTracking(prevTracking) - if ok then - return ... - else - error((...)) - end -end - -local function untracked(callback: (Args...) -> Results..., ...: Args...): Results... - return handleUntracked(setCurrentTracking(false), pcall(callback, ...)) -end - -return untracked diff --git a/packages/charm/test/src/utils.luau b/packages/charm/test/src/utils.luau deleted file mode 100644 index 536e086..0000000 --- a/packages/charm/test/src/utils.luau +++ /dev/null @@ -1,18 +0,0 @@ -local function handleNoYield(thread: thread, ok: boolean, ...) - if not ok then - return false, debug.traceback(thread, tostring((...))) - elseif coroutine.status(thread) ~= "dead" then - return false, debug.traceback(thread, "Attempted to yield!") - else - return true, ... - end -end - -local function noYield(callback: (Args...) -> Results..., ...: Args...): (boolean, Results...) - local thread = coroutine.create(callback) - return handleNoYield(thread, coroutine.resume(thread, ...)) -end - -return { - noYield = noYield :: typeof(pcall), -} diff --git a/packages/charm/test/wally.toml b/packages/charm/test/wally.toml deleted file mode 100644 index cd94b61..0000000 --- a/packages/charm/test/wally.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "littensy/charm" -version = "0.10.0" -registry = "https://github.com/UpliftGames/wally-index" -realm = "shared" -exclude = ["node_modules", "src/__tests__", "src/index.d.ts", "package.json"] - -[dependencies] diff --git a/packages/react-charm/src/__tests__/helpers/renderHook.luau b/packages/react-charm/src/__tests__/helpers/renderHook.luau index 664b2fc..6a3ea48 100644 --- a/packages/react-charm/src/__tests__/helpers/renderHook.luau +++ b/packages/react-charm/src/__tests__/helpers/renderHook.luau @@ -13,11 +13,11 @@ local function renderHook(callback: (Props) -> Result, props: Pro local root = ReactRoblox.createLegacyRoot(Instance.new("Folder")) local hook = {} - local function Component(props) + local function Component(props: Props) hook.result = callback(props) end - function hook.rerender(newProps) + function hook.rerender(newProps: Props?) props = newProps or props ReactRoblox.act(function() root:render(React.createElement(Component, props)) diff --git a/packages/react-charm/src/__tests__/spec.luau b/packages/react-charm/src/__tests__/spec.luau index 37326fa..2dcdfe7 100644 --- a/packages/react-charm/src/__tests__/spec.luau +++ b/packages/react-charm/src/__tests__/spec.luau @@ -1,15 +1,11 @@ local suite = require("../../../../test/suite") -suite.describe("react-charm", function() - if not game then - return suite.skip() - end - suite.describe("useAtom", function() - require("./useAtom.spec") - end) - suite.describe("useAtomBinding", function() - require("./useAtomBinding.spec") - end) +suite.describe("useAtom", function() + require("./useAtom.spec") +end) + +suite.describe("useAtomBinding", function() + require("./useAtomBinding.spec") end) return {} diff --git a/packages/react-charm/src/index.d.ts b/packages/react-charm/src/index.d.ts index cb0ff5c..f24d4af 100644 --- a/packages/react-charm/src/index.d.ts +++ b/packages/react-charm/src/index.d.ts @@ -1 +1,29 @@ -export {}; +import { Binding } from "@rbxts/react"; + +/** + * A hook that subscribes to changes in the given atom or selector. The + * component is re-rendered with the new state after an update. + * + * If the `dependencies` array is provided, a new effect is created for the + * current `callback` when the dependencies change. Otherwise, the effect is + * created only once, when the component is mounted. + * + * @param callback The atom or selector to watch. + * @param dependencies An array of values used to memoize the callback. + * @returns The current state. + */ +export function useAtom(callback: () => T, dependencies?: any[]): T; + +/** + * A hook that subscribes to changes in the given atom or selector. Returns + * a binding that updates when the state changes. + * + * If the `dependencies` array is provided, a new effect is created for the + * current `callback` when the dependencies change. Otherwise, the effect is + * created only once, when the component is mounted. + * + * @param callback The atom or selector to watch. + * @param dependencies An array of values used to memoize the callback. + * @returns A binding that stores the current state. + */ +export function useAtomBinding(callback: () => T, dependencies?: any[]): Binding; diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 0ddbdd0..02976a2 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -8,21 +8,21 @@ local useState = React.useState local useMemo = React.useMemo --- A hook that subscribes to changes in the given atom or selector. The ---- component is re-rendered whenever the state changes. +--- component is re-rendered with the new state after an update. --- ---- If the `dependencies` array is provided, the effect is re-created whenever ---- the dependencies change. Otherwise, the subscription is created once when ---- the component is mounted. +--- If the `dependencies` array is provided, a new effect is created for the +--- current `callback` when the dependencies change. Otherwise, the effect is +--- created only once, when the component is mounted. --- ---- @param selector The atom or selector to subscribe to. ---- @param dependencies An array of values that the subscription depends on. +--- @param callback The atom or selector to watch. +--- @param dependencies An array of values used to memoize the callback. --- @return The current state. -local function useAtom(selector: () -> State, dependencies: { any }?): State - local state, setState = useState(selector) +local function useAtom(callback: () -> T, dependencies: { any }?): T + local state, setState = useState(callback) useEffect(function() return effect(function() - setState(selector()) + setState(callback()) end) end, dependencies or {}) @@ -32,20 +32,20 @@ end --- A hook that subscribes to changes in the given atom or selector. Returns --- a binding that updates when the state changes. --- ---- If the `dependencies` array is provided, the effect is re-created whenever ---- the dependencies change. Otherwise, the subscription is created once when ---- the component is mounted. +--- If the `dependencies` array is provided, a new effect is created for the +--- current `callback` when the dependencies change. Otherwise, the effect is +--- created only once, when the component is mounted. --- ---- @param selector The atom or selector to subscribe to. ---- @param dependencies An array of values that the subscription depends on. +--- @param callback The atom or selector to watch. +--- @param dependencies An array of values used to memoize the callback. --- @return A binding that stores the current state. -local function useAtomBinding(selector: () -> State, dependencies: { any }?) - local initialState = useMemo(selector, {}) +local function useAtomBinding(callback: () -> T, dependencies: { any }?) + local initialState = useMemo(callback, {}) local binding, setBinding = useBinding(initialState) useEffect(function() return effect(function() - setBinding(selector()) + setBinding(callback()) end) end, dependencies or {}) diff --git a/packages/vide-charm/src/__tests__/spec.luau b/packages/vide-charm/src/__tests__/spec.luau index c445ac5..fddcda9 100644 --- a/packages/vide-charm/src/__tests__/spec.luau +++ b/packages/vide-charm/src/__tests__/spec.luau @@ -1,12 +1,7 @@ local suite = require("../../../../test/suite") -suite.describe("vide-charm", function() - if not game then - return suite.skip() - end - suite.describe("useAtom", function() - require("./useAtom.spec") - end) +suite.describe("useAtom", function() + require("./useAtom.spec") end) return {} diff --git a/packages/vide-charm/src/index.d.ts b/packages/vide-charm/src/index.d.ts index cb0ff5c..f9541ab 100644 --- a/packages/vide-charm/src/index.d.ts +++ b/packages/vide-charm/src/index.d.ts @@ -1 +1,8 @@ -export {}; +/** + * Subscribes to the state of an atom or selector. Returns a source that + * updates when the state changes. + * + * @param callback The atom or selector to subscribe to. + * @returns The reactive source. + */ +export function useAtom(callback: () => T, dependencies?: any[]): () => T; diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index 766e57b..ddbb727 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -8,11 +8,11 @@ local cleanup = Vide.cleanup --- Subscribes to the state of an atom or selector. Returns a source that --- updates when the state changes. --- ---- @param selector The atom or selector to subscribe to. +--- @param callback The atom or selector to subscribe to. --- @return The reactive source. -local function useAtom(selector: () -> State): () -> State - local state = source(selector()) - cleanup(subscribe(selector, state)) +local function useAtom(callback: () -> State): () -> State + local state = source(callback()) + cleanup(subscribe(callback, state)) return state end diff --git a/test/spec.server.luau b/test/spec.server.luau index b33b7b2..46abc3b 100644 --- a/test/spec.server.luau +++ b/test/spec.server.luau @@ -1,11 +1,22 @@ local Charm = require("../packages/charm/src") local suite = require("./suite") -Charm.strict.enabled = true +Charm.flags.strict = true -require("../packages/charm/src/__tests__/spec") -require("../packages/charm-sync/src/__tests__/spec") -require("../packages/react-charm/src/__tests__/spec") -require("../packages/vide-charm/src/__tests__/spec") +suite.describe("charm", function() + require("../packages/charm/src/__tests__/spec") +end) + +suite.describe.ingame("charm-sync", function() + require("../packages/charm-sync/src/__tests__/spec") +end) + +suite.describe.ingame("react-charm", function() + require("../packages/react-charm/src/__tests__/spec") +end) + +suite.describe.ingame("vide-charm", function() + require("../packages/vide-charm/src/__tests__/spec") +end) suite.finish() diff --git a/test/suite b/test/suite index 2692eb0..eb95781 160000 --- a/test/suite +++ b/test/suite @@ -1 +1 @@ -Subproject commit 2692eb055839cd56d0075ca72482658a5bdaddb9 +Subproject commit eb95781a86e505cb6bff83ca568a67c7295ebb38