refactor: use new type solver

This commit is contained in:
littensy 2026-04-02 06:49:54 -07:00
parent 748dbc5014
commit 22b440e35f
32 changed files with 169 additions and 155 deletions

View file

@ -26,7 +26,7 @@ jobs:
pnpm prettier --check packages pnpm prettier --check packages
- name: Run tests - name: Run tests
run: lute run tests run: lute run test
- name: Build models - name: Build models
run: lute run build run: lute run build

16
.luaurc
View file

@ -1,10 +1,10 @@
{ {
"languageMode": "strict", "languageMode": "strict",
"aliases": { "aliases": {
"lute": "~/.lute/typedefs/0.1.0/lute", "lute": "~/.lute/typedefs/0.1.0/lute",
"std": "~/.lute/typedefs/0.1.0/std" "std": "~/.lute/typedefs/0.1.0/std"
}, },
"lint": { "lint": {
"LocalShadow": false "LocalShadow": false
} }
} }

View file

@ -16,6 +16,7 @@ local analyze = process.run({
"--ignore=**/node_modules/**", "--ignore=**/node_modules/**",
"--base-luaurc=.luaurc", "--base-luaurc=.luaurc",
"--definitions=roblox.d.luau", "--definitions=roblox.d.luau",
"--flag:LuauSolverV2=true",
"packages", "packages",
"tests", "tests",
"benches", "benches",

1
.lute/test.luau Normal file
View file

@ -0,0 +1 @@
(require)("../tests/run.server")

View file

@ -51,7 +51,7 @@ Charm is a state management library based on [fine-grained reactivity](https://d
- [`subscribe(getter, callback)`](#subscribegetter-callback) - [`subscribe(getter, callback)`](#subscribegetter-callback)
- [`untracked(callback)`](#untrackedcallback) - [`untracked(callback)`](#untrackedcallback)
- [`batch(callback)`](#batchcallback) - [`batch(callback)`](#batchcallback)
- [`mapped(getter, mapper)`](#mappedgetter-mapper) - [`mapped(getter, transform)`](#mappedgetter-transform)
- [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently)
- [`atom(initialValue, equals?)`](#atominitialvalue-equals) - [`atom(initialValue, equals?)`](#atominitialvalue-equals)
- [`trigger(callback)`](#triggercallback) - [`trigger(callback)`](#triggercallback)
@ -472,11 +472,11 @@ end)
--- ---
### `mapped(getter, mapper)` ### `mapped(getter, transform)`
The `mapped` function iterates over every key in a table and uses the mapper to assign them to a new key and value. The result is returned as a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, the mapper is called for that key and its current value. The `mapped` function iterates over every key in a table and uses the transform function to assign them to a new key and value. The result is returned as a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, `transform` is called for that key and its current value.
The first value returned by the mapper is used as the new value: The first value returned by the transform function is used as the new value:
```luau ```luau
local getList, setList = signal({ "a", "b", "c" }) local getList, setList = signal({ "a", "b", "c" })
@ -488,7 +488,7 @@ end)
print(getUppercase()) -- { "A", "B", "C" } print(getUppercase()) -- { "A", "B", "C" }
``` ```
If the mapper returns two values, the second value is used as the new key: If the transform function returns two values, the second value is used as the new key:
```luau ```luau
local getList, setList = signal({ "a", "b", "c" }) local getList, setList = signal({ "a", "b", "c" })

View file

@ -1,7 +1,7 @@
{ {
"private": true, "private": true,
"scripts": { "scripts": {
"test": "lute run tests", "test": "lute run test",
"build": "lute run build", "build": "lute run build",
"check": "lute run check", "check": "lute run check",
"publish:npm": "pnpm publish -r --access public", "publish:npm": "pnpm publish -r --access public",

View file

@ -3,7 +3,7 @@ local patch = require("./patch")
local types = require("./types") local types = require("./types")
type SyncPayload = types.SyncPayload type SyncPayload = types.SyncPayload
type ReactiveProxy<T = {}> = types.ReactiveProxy<T> type ReactiveProxy<T = { [any]: any }> = types.ReactiveProxy<T>
local trackedSignals: { [string]: (any) -> any } = {} local trackedSignals: { [string]: (any) -> any } = {}
local trackedReactives: { [string]: ReactiveProxy } = {} local trackedReactives: { [string]: ReactiveProxy } = {}

View file

@ -39,7 +39,7 @@ end
long as the target array is not sparse. This function ensures that the diff long as the target array is not sparse. This function ensures that the diff
function produces a patch that can be correctly applied. function produces a patch that can be correctly applied.
]] ]]
local function stringifySparseArray(object: {}): { [string | number]: any } local function stringifySparseArray(object: { [any]: any }): { [string | number]: any }
local maxn = table.maxn(object) local maxn = table.maxn(object)
if maxn == 0 or maxn == #object then if maxn == 0 or maxn == #object then

View file

@ -39,8 +39,9 @@ local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())?
support custom implementations of reactive proxies. support custom implementations of reactive proxies.
]] ]]
local function toRaw<T>(value: T): T local function toRaw<T>(value: T): T
local value: any = value
if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then
value.__track() (value.__track :: () -> ())()
return toRaw(value.__target) return toRaw(value.__target)
end end
return value return value
@ -153,15 +154,17 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
local status = clientKeys[key] local status = clientKeys[key]
if status == LISTENING_FOR_CHANGES then if status == LISTENING_FOR_CHANGES then
table.insert(payloads, { local payload: SyncPayload = {
type = "patch", type = "patch",
data = { [key] = patchData }, data = { [key] = patchData },
}) }
table.insert(payloads, payload)
elseif status == PENDING_INITIAL_STATE then elseif status == PENDING_INITIAL_STATE then
table.insert(payloads, { local payload: SyncPayload = {
type = "init", type = "init",
data = { [key] = update.newValue }, data = { [key] = update.newValue },
}) }
table.insert(payloads, payload)
clientKeys[key] = LISTENING_FOR_CHANGES clientKeys[key] = LISTENING_FOR_CHANGES
end end
end end
@ -228,11 +231,19 @@ local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }
end end
if next(initData) ~= nil then if next(initData) ~= nil then
table.insert(payloads, { type = "init", data = initData }) local payload: SyncPayload = {
type = "init",
data = initData,
}
table.insert(payloads, payload)
end end
if next(patchData) ~= nil then if next(patchData) ~= nil then
table.insert(payloads, { type = "patch", data = patchData }) local payload: SyncPayload = {
type = "patch",
data = patchData,
}
table.insert(payloads, payload)
end end
return payloads return payloads

View file

@ -2,10 +2,6 @@ local Charm = require("../Charm")
type Atom<T> = Charm.Atom<T> type Atom<T> = Charm.Atom<T>
type NestedTable = {
[any]: NestedTable | any,
}
--[[ --[[
Flattens a nested table into a single table with slash-separated keys. Flattens a nested table into a single table with slash-separated keys.
Useful for recursively collecting atoms returned by modules. Useful for recursively collecting atoms returned by modules.
@ -13,15 +9,15 @@ type NestedTable = {
@param input A nested table. @param input A nested table.
@return A flattened table with slash-separated keys. @return A flattened table with slash-separated keys.
]] ]]
local function flatten<Flattened>(input: {}): Flattened local function flatten<Flattened>(input: { [any]: any }): Flattened
local result = {} local result = {}
local function visit(node: {}, path: string) local function visit(node: { [any]: any }, path: string)
for key, value in node do for key, value in node do
local location = if path == "" then tostring(key) else path .. "/" .. tostring(key) local location = if path == "" then tostring(key) else path .. "/" .. tostring(key)
if type(value) == "table" then if type(value) == "table" then
visit(value :: {}, location) visit(value, location)
else else
result[location] = value result[location] = value
end end

View file

@ -1,4 +1,4 @@
local function isTableUnsafe(object: {}) local function isTableUnsafe(object: { [any]: any })
local key = next(object) local key = next(object)
if key == nil then if key == nil then
@ -31,9 +31,8 @@ end
with remote argument limitations, such as sparse arrays or mixed tables. with remote argument limitations, such as sparse arrays or mixed tables.
@param value The table value. @param value The table value.
@param key The key of the value.
]] ]]
local function validate(value: {}) local function validate(value: { [any]: any })
if isTableUnsafe(value) then if isTableUnsafe(value) then
warn( warn(
`Cannot sync tables unsupported by remote events! See the table below for more info.\n\n` `Cannot sync tables unsupported by remote events! See the table below for more info.\n\n`

View file

@ -2,7 +2,7 @@ local config = require("../src/config")
local patch = require("../src/patch") local patch = require("../src/patch")
local test = require("../../../tests/test") local test = require("../../../tests/test")
local function mockArraySerdes(object: {}): {} local function mockArraySerdes(object: { [any]: any }): { [any]: any }
if object[1] then if object[1] then
local copy = {} local copy = {}
for index = 1, #object do for index = 1, #object do

View file

@ -4,6 +4,7 @@ local config = require("../src/config")
local patch = require("../src/patch") local patch = require("../src/patch")
local server = require("../src/server") local server = require("../src/server")
local test = require("../../../tests/test") local test = require("../../../tests/test")
local types = require("../src/types")
test.afterEach(function() test.afterEach(function()
server.reset() server.reset()
@ -14,7 +15,7 @@ test("should replicate state changes", function()
local a = Charm.atom(0) local a = Charm.atom(0)
local b = Charm.atom(0) local b = Charm.atom(0)
local c = Charm.atom(0) local c = Charm.atom(0)
local target, payloads local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b, c = c }) server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...) server.connect(function(...)
@ -41,7 +42,7 @@ test("should replicate partial state patches", function()
local client = {} local client = {}
local a = Charm.atom({ x = 0, y = 0 }) local a = Charm.atom({ x = 0, y = 0 })
local b = Charm.atom(0 :: number?) local b = Charm.atom(0 :: number?)
local target, payloads local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b }) server.addSignalsToClient(client, { a = a, b = b })
server.connect(function(...) server.connect(function(...)
@ -68,7 +69,7 @@ end)
test("should replicate partial state patches (reactive)", function() test("should replicate partial state patches (reactive)", function()
local client = {} local client = {}
local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? }) local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? })
local target, payloads local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a }) server.addSignalsToClient(client, { a = a })
server.connect(function(...) server.connect(function(...)
@ -118,7 +119,7 @@ test("should init new clients", function()
local clientA = {} local clientA = {}
local clientB = {} local clientB = {}
local a = Charm.atom(0) local a = Charm.atom(0)
local payloadsA, payloadsB local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
server.addSignalsToClient(clientA, { a = a }) server.addSignalsToClient(clientA, { a = a })
server.connect(function(target, payloads) server.connect(function(target, payloads)
@ -147,7 +148,7 @@ test("should init new atoms", function()
local client = {} local client = {}
local a = Charm.atom(0) local a = Charm.atom(0)
local b = Charm.atom(0) local b = Charm.atom(0)
local target, payloads local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a }) server.addSignalsToClient(client, { a = a })
server.connect(function(...) server.connect(function(...)
@ -172,7 +173,7 @@ test("should only replicate to synced clients", function()
local clientB = {} local clientB = {}
local a = Charm.atom(0) local a = Charm.atom(0)
local b = Charm.atom(0) local b = Charm.atom(0)
local payloadsA, payloadsB local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
server.addSignalsToClient(clientA, { a = a }) server.addSignalsToClient(clientA, { a = a })
server.addSignalsToClient(clientB, { b = b }) server.addSignalsToClient(clientB, { b = b })
@ -214,7 +215,7 @@ end)
test("should serialize state", function() test("should serialize state", function()
local client = {} local client = {}
local a = Charm.atom({ 1, 2, 3, 4, 5 }) local a = Charm.atom({ 1, 2, 3, 4, 5 })
local payloads local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a }) server.addSignalsToClient(client, { a = a })
server.connect(function(_, p) server.connect(function(_, p)
@ -231,7 +232,7 @@ end)
test("should not send unchanged state", function() test("should not send unchanged state", function()
local client = {} local client = {}
local a = Charm.atom(0) local a = Charm.atom(0)
local payloads local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a }) server.addSignalsToClient(client, { a = a })
server.connect(function(_, p) server.connect(function(_, p)
@ -262,7 +263,7 @@ test.describe("historical", function()
local a = Charm.atom(0) local a = Charm.atom(0)
local b = Charm.atom(0) local b = Charm.atom(0)
local c = Charm.atom(0) local c = Charm.atom(0)
local target, payloads local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b, c = c }) server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...) server.connect(function(...)
@ -292,7 +293,7 @@ test.describe("historical", function()
local a = DeepCharm.reactive({ value = 0 }) local a = DeepCharm.reactive({ value = 0 })
local b = DeepCharm.reactive({ value = 0 }) local b = DeepCharm.reactive({ value = 0 })
local c = DeepCharm.reactive({ value = 0 }) local c = DeepCharm.reactive({ value = 0 })
local target, payloads local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b, c = c }) server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...) server.connect(function(...)
@ -321,7 +322,7 @@ test.describe("historical", function()
local clientA = {} local clientA = {}
local clientB = {} local clientB = {}
local a = Charm.atom(0) local a = Charm.atom(0)
local payloadsA, payloadsB local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
server.addSignalsToClient(clientA, { a = a }) server.addSignalsToClient(clientA, { a = a })
server.connect(function(target, payloads) server.connect(function(target, payloads)
@ -357,7 +358,7 @@ test.describe("historical", function()
local client = {} local client = {}
local a = Charm.atom(0) local a = Charm.atom(0)
local b = Charm.atom(0) local b = Charm.atom(0)
local payloads local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a }) server.addSignalsToClient(client, { a = a })
server.connect(function(_, ...) server.connect(function(_, ...)
@ -386,7 +387,7 @@ test.describe("historical", function()
test("should not send unchanged state", function() test("should not send unchanged state", function()
local client = {} local client = {}
local a = Charm.atom(0) local a = Charm.atom(0)
local payloads local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a }) server.addSignalsToClient(client, { a = a })
server.connect(function(_, p) server.connect(function(_, p)

View file

@ -8,7 +8,8 @@ test("should flatten functions", function()
b = { c = Charm.atom(0) }, b = { c = Charm.atom(0) },
d = { e = { f = utils.signalToAtom(Charm.signal(0)) } }, d = { e = { f = utils.signalToAtom(Charm.signal(0)) } },
} }
local result = utils.flatten(input)
local result = utils.flatten<<{ [string]: Charm.Atom<number> }>>(input)
assert(result.a == input.a, "should flatten the first level") assert(result.a == input.a, "should flatten the first level")
assert(result["b/c"] == input.b.c, "should flatten the second level") assert(result["b/c"] == input.b.c, "should flatten the second level")

View file

@ -180,11 +180,11 @@ export function batch<Args extends any[], Result>(fn: (...args: Args) => Result,
* value and the previous value as arguments. * value and the previous value as arguments.
* *
* @param getter A function that returns the value to watch. * @param getter A function that returns the value to watch.
* @param fn The function to run when the value changes. * @param callback The function to run when the value changes.
* @returns A function for disposing the effect. * @returns A function for disposing the effect.
* @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback * @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
*/ */
export function listen<T>(getter: Getter<T>, fn: (value: T, previousValue: T | undefined) => void): Cleanup; export function listen<T>(getter: Getter<T>, callback: (value: T, previousValue: T | undefined) => void): Cleanup;
/** /**
* Creates an effect that only runs the callback when the value returned by the * Creates an effect that only runs the callback when the value returned by the
@ -192,34 +192,37 @@ export function listen<T>(getter: Getter<T>, fn: (value: T, previousValue: T | u
* as arguments. * as arguments.
* *
* @param getter A function that returns the value to watch. * @param getter A function that returns the value to watch.
* @param fn The function to run when the value changes. * @param callback The function to run when the value changes.
* @returns A function for disposing the effect. * @returns A function for disposing the effect.
* @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback * @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
*/ */
export function subscribe<T>(getter: Getter<T>, fn: (value: T, previousValue: T) => void): Cleanup; export function subscribe<T>(getter: Getter<T>, callback: (value: T, previousValue: T) => void): Cleanup;
/** /**
* Creates a new read-only signal that is computed by mapping over the entries * Creates a new read-only signal that is computed by mapping over the entries
* of an existing atom. If the key is omitted from the mapper's return value, * of the source table. If the key is omitted from the transform function's
* the original key is preserved. * return value, the original key is preserved.
* *
* @param getter Returns the table to map over. * This function is optimized to minimize unnecessary updates by only calling
* @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. * the transform function for entries that have changed since the last run.
* @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper *
* @param getter A signal or function that returns a table
* @param transform Function that maps each entry from the source to a new entry in the mapped table
* @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform
*/ */
export function mapped<VI, KI, VO, KO = KI>( export function mapped<VI, KI, VO, KO = KI>(
getter: Getter<Map<KI, VI> | ReadonlyMap<KI, VI>>, getter: Getter<Map<KI, VI> | ReadonlyMap<KI, VI>>,
mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
): Getter<ReadonlyMap<KO, VO>>; ): Getter<ReadonlyMap<KO, VO>>;
// Overload for arrays // Overload for arrays
export function mapped<VI, VO, K extends Key = number>( export function mapped<VI, VO, K extends Key = number>(
getter: Getter<readonly VI[]>, getter: Getter<readonly VI[]>,
mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, transform: (value: VI, key: number) => LuaTuple<[VO, K]> | VO,
): Getter<K extends number ? readonly VO[] : { readonly [P in K]: VO }>; ): Getter<K extends number ? readonly VO[] : { readonly [P in K]: VO }>;
// Overload for objects // Overload for objects
export function mapped<VI, KI extends Key, VO, KO extends Key = KI>( export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
getter: Getter<{ readonly [K in KI]: VI }>, getter: Getter<{ readonly [K in KI]: VI }>,
mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
): Getter<{ readonly [K in KO]: VO }>; ): Getter<{ readonly [K in KO]: VO }>;
/** /**
@ -234,18 +237,18 @@ export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
* from the table or when the callback is disposed. * from the table or when the callback is disposed.
* *
* @param getter A function that returns the table to observe. * @param getter A function that returns the table to observe.
* @param fn A function that is called for each key in the table. * @param callback A function that is called for each key in the table.
* @returns A function for disposing the observer. * @returns A function for disposing the observer.
* @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
*/ */
export function observe<V, K = number>( export function observe<V, K = number>(
getter: Getter<Map<K, V> | ReadonlyMap<K, V> | readonly V[]>, getter: Getter<Map<K, V> | ReadonlyMap<K, V> | readonly V[]>,
fn: (value: V, key: K) => Cleanup | void, callback: (value: V, key: K) => Cleanup | void,
): Cleanup; ): Cleanup;
// Overload for objects // Overload for objects
export function observe<V, K extends Key>( export function observe<V, K extends Key>(
getter: Getter<{ readonly [K in Key]: V }>, getter: Getter<{ readonly [K in Key]: V }>,
fn: (value: V, key: K) => Cleanup | void, callback: (value: V, key: K) => Cleanup | void,
): Cleanup; ): Cleanup;
/** /**

View file

@ -96,12 +96,12 @@ local flags = {
Wraps a function to prevent yielding when in strict mode. If the function Wraps a function to prevent yielding when in strict mode. If the function
attempts to yield, an error is thrown. attempts to yield, an error is thrown.
@param fn The function to wrap. @param callback The function to wrap.
@return A wrapped function that cannot yield in strict mode. @return A wrapped function that cannot yield in strict mode.
]] ]]
local function noYield<Args..., Results...>(fn: (Args...) -> Results...): (Args...) -> Results... local function noYield<Args..., Results...>(callback: (Args...) -> Results...): (Args...) -> Results...
if not flags.strict then if not flags.strict then
return fn return callback
end end
local function noYieldHandler(thread: thread, ok: boolean, ...) local function noYieldHandler(thread: thread, ok: boolean, ...)
@ -114,7 +114,7 @@ local function noYield<Args..., Results...>(fn: (Args...) -> Results...): (Args.
end end
return function(...) return function(...)
local thread = coroutine.create(fn) local thread = coroutine.create(callback)
return noYieldHandler(thread, coroutine.resume(thread, ...)) return noYieldHandler(thread, coroutine.resume(thread, ...))
end end
end end
@ -195,7 +195,7 @@ local function runCleanups(cleanups: { Cleanup })
end end
if hasErrors then if hasErrors then
error(`Errors occurred during effect cleanup:\n\n${table.concat(errors, "\n\n")}`, 0) error(`Errors occurred during effect cleanup:\n\n{table.concat(errors, "\n\n")}`, 0)
end end
end end
@ -206,9 +206,12 @@ end
@param effect Effect node to run @param effect Effect node to run
]] ]]
local function run(effect: EffectNode) local function run(effect: EffectNode)
local flags = effect.flags local effectFlags = effect.flags
if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then if
bit32.btest(effectFlags, DIRTY)
or (bit32.btest(effectFlags, PENDING) and checkDirty(effect.deps :: Link, effect))
then
runCleanups(effect.cleanups) runCleanups(effect.cleanups)
if effect.flags == NONE then if effect.flags == NONE then
@ -342,10 +345,10 @@ end
inline the operation. inline the operation.
@param sub Effect or computed node @param sub Effect or computed node
@param flags Subscriber's current flags @param subFlags Subscriber's current flags
]] ]]
local function clearPending(sub: ReactiveNode, flags: ReactiveFlags) local function clearPending(sub: ReactiveNode, subFlags: ReactiveFlags)
sub.flags = bit32.band(flags, bit32.bnot(PENDING)) sub.flags = bit32.band(subFlags, bit32.bnot(PENDING))
end end
--[[ --[[
@ -463,14 +466,14 @@ local function signal<T>(initialValue: T, equals: Equals<T>?): (Getter<T>, Sette
deepFreeze(initialValue) deepFreeze(initialValue)
end end
local function writeSignal(update: Update<T>): T local function writeSignal(newValue: Update<T>): T
local value: T local value: T
-- Determine the new value based on whether the update is a function or -- Determine the new value based on whether the update is a function or
-- a direct value -- a direct value
if type(update) == "function" then if type(newValue) == "function" then
local prevSub = setActiveSub(nil) local prevSub = setActiveSub(nil)
local success, result = pcall(noYield(update), this.pendingValue) local success, result = pcall(noYield(newValue), this.pendingValue)
activeSub = prevSub activeSub = prevSub
if success then if success then
@ -479,7 +482,7 @@ local function signal<T>(initialValue: T, equals: Equals<T>?): (Getter<T>, Sette
error(result, 2) error(result, 2)
end end
else else
value = update value = newValue
end end
-- Check if the new value is different from the pending value -- Check if the new value is different from the pending value
@ -565,11 +568,14 @@ local function computed<T>(getter: (previousValue: T?) -> T): Getter<T>
} }
local function computedOper(): T local function computedOper(): T
local flags = this.flags local computedFlags = this.flags
if if
bit32.btest(flags, DIRTY) bit32.btest(computedFlags, DIRTY)
or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or clearPending(this, flags))) or (
bit32.btest(computedFlags, PENDING)
and (checkDirty(this.deps :: Link, this) or clearPending(this, computedFlags))
)
then then
if updateComputed(this) then if updateComputed(this) then
-- Notify subscribers if the value changed -- Notify subscribers if the value changed
@ -578,7 +584,7 @@ local function computed<T>(getter: (previousValue: T?) -> T): Getter<T>
shallowPropagate(subs) shallowPropagate(subs)
end end
end end
elseif flags == NONE then elseif computedFlags == NONE then
-- First access should initialize the computed value -- First access should initialize the computed value
this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) this.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
@ -694,7 +700,7 @@ end
@param fn A signal or function that accesses signals @param fn A signal or function that accesses signals
@see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback
]] ]]
local function trigger(fn: () -> ()) local function trigger(fn: () -> ...any)
local sub: ReactiveNode = { local sub: ReactiveNode = {
flags = WATCHING, flags = WATCHING,
} }
@ -707,10 +713,10 @@ local function trigger(fn: () -> ())
error(result, 2) error(result, 2)
end end
local link = sub.deps local currentLink = sub.deps
while link do while currentLink do
local dep = link.dep local dep = currentLink.dep
link = unlink(link, sub) currentLink = unlink(currentLink, sub)
local subs = dep.subs local subs = dep.subs
if subs then if subs then
sub.flags = NONE sub.flags = NONE
@ -808,18 +814,18 @@ end
new value and the previous value as arguments. new value and the previous value as arguments.
@param getter A signal or function that accesses signals @param getter A signal or function that accesses signals
@param fn Function to run when the value changes @param callback Function to run when the value changes
@return A cleanup function that stops the effect @return A cleanup function that stops the effect
@see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
]] ]]
local function listen<T>(getter: Getter<T>, fn: (state: T, lastState: T?) -> ()): Cleanup local function listen<T>(getter: Getter<T>, callback: (state: T, lastState: T?) -> ()): Cleanup
local tracked: Getter<T> = if isSignal(getter) then getter else computed(getter) local tracked: Getter<T> = if isSignal(getter) then getter else computed(getter :: (T?) -> T)
local current: T local current: T
return effect(function() return effect(function()
local previous = current local previous = current
current = tracked() current = tracked()
untracked(fn, current, previous) untracked(callback, current, previous)
end) end)
end end
@ -829,12 +835,12 @@ end
value as arguments. value as arguments.
@param getter A signal or function that accesses signals @param getter A signal or function that accesses signals
@param fn Function to run when the value changes @param callback Function to run when the value changes
@return A cleanup function that stops the effect @return A cleanup function that stops the effect
@see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
]] ]]
local function subscribe<T>(getter: Getter<T>, fn: (state: T, lastState: T) -> ()): Cleanup local function subscribe<T>(getter: Getter<T>, callback: (state: T, lastState: T) -> ()): Cleanup
local tracked: Getter<T> = if isSignal(getter) then getter else computed(getter) local tracked: Getter<T> = if isSignal(getter) then getter else computed(getter :: (T?) -> T)
local current: T local current: T
local firstRun = true local firstRun = true
@ -844,7 +850,7 @@ local function subscribe<T>(getter: Getter<T>, fn: (state: T, lastState: T) -> (
if firstRun then if firstRun then
firstRun = false firstRun = false
else else
untracked(fn, current, previous) untracked(callback, current, previous)
end end
end) end)
end end
@ -859,11 +865,11 @@ end
during the callback will also be bound to the lifetime of the key. during the callback will also be bound to the lifetime of the key.
@param getter A signal or function that returns a table @param getter A signal or function that returns a table
@param fn Function that is called for each key added to the table @param callback Function that is called for each key added to the table
@return A cleanup function that disposes scopes for all keys @return A cleanup function that disposes scopes for all keys
@see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
]] ]]
local function observe<K, V>(getter: Getter<{ [K]: V }>, fn: (item: V, key: K) -> ...Cleanup?): Cleanup local function observe<K, V>(getter: Getter<{ [K]: V }>, callback: (item: V, key: K) -> ...Cleanup?): Cleanup
local scopes: { [K]: Cleanup } = {} local scopes: { [K]: Cleanup } = {}
local stopObserving = false local stopObserving = false
@ -881,7 +887,7 @@ local function observe<K, V>(getter: Getter<{ [K]: V }>, fn: (item: V, key: K) -
for key, value in data do for key, value in data do
if scopes[key] == nil then if scopes[key] == nil then
local dispose = effectScope(function() local dispose = effectScope(function()
return fn(value, key) return callback(value, key)
end, true) end, true)
if stopObserving then if stopObserving then
dispose() dispose()
@ -911,17 +917,17 @@ end
--[[ --[[
Creates a new read-only signal that is computed by mapping over the entries Creates a new read-only signal that is computed by mapping over the entries
of an existing atom. If the key is omitted from the mapper's return value, of the source table. If the key is omitted from the transform function's
the original key is preserved. return value, the original key is preserved.
This function is optimized to minimize unnecessary updates by only calling This function is optimized to minimize unnecessary updates by only calling
the mapper for entries that have changed since the last run. the transform function for entries that have changed since the last run.
@param getter A signal or function that returns a table @param getter A signal or function that returns a table
@param mapper Function that maps each entry from the source to a new entry in the mapped table @param transform Function that maps each entry from the source to a new entry in the mapped table
@see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform
]] ]]
local function mapped<KI, VI, KO, VO>(getter: Getter<{ [KI]: VI }>, mapper: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> local function mapped<KI, VI, KO, VO>(getter: Getter<{ [KI]: VI }>, transform: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }>
local nextInput: { [KI]: VI } = {} local nextInput: { [KI]: VI } = {}
local keyMap: { [KI]: KO } = {} local keyMap: { [KI]: KO } = {}
@ -957,7 +963,7 @@ local function mapped<KI, VI, KO, VO>(getter: Getter<{ [KI]: VI }>, mapper: (VI,
continue continue
end end
local nextValueOut, keyOutOrNil = mapper(nextValueIn, keyIn) local nextValueOut, keyOutOrNil = transform(nextValueIn, keyIn)
local nextKeyOut: KO = if keyOutOrNil ~= nil then keyOutOrNil else keyIn :: any local nextKeyOut: KO = if keyOutOrNil ~= nil then keyOutOrNil else keyIn :: any
local prevKeyOut = keyMap[keyIn] local prevKeyOut = keyMap[keyIn]
@ -999,8 +1005,8 @@ return {
observe = observe, observe = observe,
mapped = mapped :: (<KI, VI, KO, VO>( mapped = mapped :: (<KI, VI, KO, VO>(
getter: Getter<{ [KI]: VI }>, getter: Getter<{ [KI]: VI }>,
mapper: (VI, KI) -> (VO, KO) transform: (VI, KI) -> (VO, KO)
) -> Getter<{ [KO]: VO }>) & (<K, VI, VO>(getter: Getter<{ [K]: VI }>, mapper: (VI, K) -> VO) -> Getter<{ [K]: VO }>), ) -> Getter<{ [KO]: VO }>) & (<K, VI, VO>(getter: Getter<{ [K]: VI }>, transform: (VI, K) -> VO) -> Getter<{ [K]: VO }>),
onCleanup = onCleanup, onCleanup = onCleanup,
getActiveSub = getActiveSub, getActiveSub = getActiveSub,
setActiveSub = setActiveSub, setActiveSub = setActiveSub,

View file

@ -84,7 +84,7 @@ end)
test("should receive previous value", function() test("should receive previous value", function()
local calls = {} local calls = {}
local src, setSrc = signal(0) local src, setSrc = signal(0)
local c1 = computed(function(current): number local c1 = computed(function(current: number?): number
local incoming = src() local incoming = src()
return if current == incoming or incoming % 2 == 1 then current or incoming else incoming return if current == incoming or incoming % 2 == 1 then current or incoming else incoming
end) end)
@ -192,10 +192,10 @@ test("should trigger effect even computed already dirty", function()
table.insert(calls, c2()) table.insert(calls, c2())
end) end)
assert(table.concat(calls, ",") == "0foo", table.concat(calls, ",")) assert(table.concat(calls, ",") == "0foo", table.concat(calls, ","))
assert(v() == 1, v()) assert(v() == 1, `expected 1 for v(), got {v()}`)
v(2) v(2)
assert(table.concat(calls, ",") == "0foo,2foo", table.concat(calls, ",")) assert(table.concat(calls, ",") == "0foo,2foo", table.concat(calls, ","))
assert(v() == 2, v()) assert(v() == 2, `expected 2 for v(), got {v()}`)
end) end)
return {} return {}

View file

@ -440,7 +440,7 @@ end)
test("should run the cleanup if the effect disposes itself", function() test("should run the cleanup if the effect disposes itself", function()
local a, setA = signal(0) local a, setA = signal(0)
local calls = 0 local calls = 0
local dispose local dispose: () -> ()
dispose = effect(function() dispose = effect(function()
if a() > 0 then if a() > 0 then
@ -460,7 +460,7 @@ end)
test("should not run the effect if the cleanup function disposes it", function() test("should not run the effect if the cleanup function disposes it", function()
local a, setA = signal(0) local a, setA = signal(0)
local calls = 0 local calls = 0
local dispose local dispose: () -> ()
dispose = effect(function() dispose = effect(function()
a() a()

View file

@ -31,8 +31,8 @@ end)
test("should run indirect scope cleanup", function() test("should run indirect scope cleanup", function()
local cleanups = 0 local cleanups = 0
local cleanup: () -> ()
local cleanup
local stopScope = effectScope(function() local stopScope = effectScope(function()
cleanup = effectScope(function() cleanup = effectScope(function()
onCleanup(function() onCleanup(function()
@ -50,8 +50,8 @@ end)
test("should run indirect effect cleanup", function() test("should run indirect effect cleanup", function()
local cleanups = 0 local cleanups = 0
local a, setA = signal(false) local a, setA = signal(false)
local stopScope = effectScope(function() local stopScope = effectScope(function()
effect(function() effect(function()
a() a()
@ -73,7 +73,7 @@ end)
test("should dispose inner effects if created in an effect", function() test("should dispose inner effects if created in an effect", function()
local source, setSource = signal(1) local source, setSource = signal(1)
local triggers = 0 local triggers = 0
local dispose local dispose: () -> ()
effect(function() effect(function()
dispose = effectScope(function() dispose = effectScope(function()

View file

@ -26,7 +26,7 @@ end
immediately closing it, causing alien-signals to unlink the computed value immediately closing it, causing alien-signals to unlink the computed value
from its dependencies and allow it to be garbage collected. from its dependencies and allow it to be garbage collected.
]] ]]
local function unlink(getter: () -> ()) local function unlink(getter: () -> unknown)
effectScope(function() effectScope(function()
getter() getter()
end)() end)()

View file

@ -95,7 +95,7 @@ test("should handle recursive additions and removals", function()
observe(items, function(item) observe(items, function(item)
if item < 5 then if item < 5 then
setItems(function(state) setItems(function(state: { number })
state = table.clone(state) state = table.clone(state)
table.insert(state, item + 1) table.insert(state, item + 1)
return state return state
@ -117,7 +117,7 @@ test("should handle self removal", function()
local edits = {} local edits = {}
observe(items, function(item, index) observe(items, function(item, index)
setItems(function(state) setItems(function(state: { number })
state = table.clone(state) state = table.clone(state)
state[index] = nil state[index] = nil
return state return state
@ -134,7 +134,7 @@ end)
test("should allow disposal during addition", function() test("should allow disposal during addition", function()
local items, setItems = signal({}) local items, setItems = signal({})
local edits = {} local edits = {}
local dispose local dispose: () -> ()
dispose = observe(items, function(item) dispose = observe(items, function(item)
if item == 1 then if item == 1 then
@ -153,7 +153,7 @@ end)
test("should allow disposal during removal", function() test("should allow disposal during removal", function()
local items, setItems = signal({ 1, 2, 3, 4 }) local items, setItems = signal({ 1, 2, 3, 4 })
local edits = {} local edits = {}
local dispose local dispose: () -> ()
dispose = observe(items, function(item) dispose = observe(items, function(item)
table.insert(edits, `+{item}`) table.insert(edits, `+{item}`)

View file

@ -6,7 +6,7 @@ local effect = Charm.effect
test("should accept equality function", function() test("should accept equality function", function()
local calls = {} local calls = {}
local src, setSrc = signal(0, function(current, incoming) local src, setSrc = signal(0, function(current: number, incoming: number)
return current == incoming or incoming % 2 == 1 return current == incoming or incoming % 2 == 1
end) end)

View file

@ -442,18 +442,16 @@ end)
test.describe("error handling", function() test.describe("error handling", function()
test("should keep graph consistent on errors during activation", function() test("should keep graph consistent on errors during activation", function()
local a = atom(0) local a = atom(0)
local b = computed(function() local b = computed(function(): nil
error("fail") error("fail")
end) end)
local c = computed(function() local c = computed(function()
return a() return a()
end) end)
local success, result = pcall(function() local success, result = pcall(b)
b()
end)
assert(not success, "should throw") assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail") assert(string.match(tostring(result), "fail"), "should throw fail")
a(1) a(1)
assert(c() == 1, "c should recover and return 1") assert(c() == 1, "c should recover and return 1")
@ -461,7 +459,7 @@ test.describe("error handling", function()
test("should keep graph consistent on errors in computeds", function() test("should keep graph consistent on errors in computeds", function()
local a = atom(0) local a = atom(0)
local b = computed(function() local b = computed(function(): number
if a() == 1 then if a() == 1 then
error("fail") error("fail")
end end
@ -474,11 +472,9 @@ test.describe("error handling", function()
assert(c() == 0, "c should be 0") assert(c() == 0, "c should be 0")
a(1) a(1)
local success, result = pcall(function() local success, result = pcall(b)
b()
end)
assert(not success, "should throw") assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail") assert(string.match(tostring(result), "fail"), "should throw fail")
a(2) a(2)
assert(c() == 2, "c should recover and return 2") assert(c() == 2, "c should recover and return 2")

View file

@ -38,7 +38,7 @@ end)
test("should run detached from effect scope", function() test("should run detached from effect scope", function()
local effects = 0 local effects = 0
local stopEffect local stopEffect: () -> ()
local stopScope = effectScope(function() local stopScope = effectScope(function()
untracked(function() untracked(function()

View file

@ -49,10 +49,10 @@ test("should unwrap proxy assignments", function()
end) end)
src.a, src.b = src.a, src.b src.a, src.b = src.a, src.b
assert(runs == 1, runs) assert(runs == 1, `bad runs: {runs}`)
src.a, src.b = src.b, src.a src.a, src.b = src.b, src.a
assert(runs == 3, runs) assert(runs == 3, `bad runs: {runs}`)
end) end)
test("should track iteration", function() test("should track iteration", function()
@ -125,10 +125,10 @@ test("should not track unread properties", function()
local _ = src.value.a local _ = src.value.a
calls += 1 calls += 1
end) end)
assert(calls == 1, calls) assert(calls == 1, `bad calls: {calls}`)
src.value.b = 3 src.value.b = 3
assert(calls == 1, calls) assert(calls == 1, `bad calls: {calls}`)
end) end)
test("should overwrite with table passed to setter", function() test("should overwrite with table passed to setter", function()
@ -159,10 +159,10 @@ test("should unwrap proxy", function()
assert(toRaw(src) == raw, "unwrap did not return the original table") assert(toRaw(src) == raw, "unwrap did not return the original table")
assert(toRaw(raw) == raw, "unwrap modified a non-proxy value") assert(toRaw(raw) == raw, "unwrap modified a non-proxy value")
assert(runs == 1, runs) assert(runs == 1, `bad runs: {runs}`)
src.x = 1 src.x = 1
assert(runs == 2, runs) assert(runs == 2, `bad runs: {runs}`)
end) end)
test("should unwrap nested proxy", function() test("should unwrap nested proxy", function()
@ -177,13 +177,13 @@ test("should unwrap nested proxy", function()
end) end)
assert(toRaw(src) == raw, "unwrap did not return the original table") assert(toRaw(src) == raw, "unwrap did not return the original table")
assert(runs == 1, runs) assert(runs == 1, `bad runs: {runs}`)
inner.x = 1 inner.x = 1
assert(runs == 2, runs) assert(runs == 2, `bad runs: {runs}`)
src.y = 2 src.y = 2
assert(runs == 3, runs) assert(runs == 3, `bad runs: {runs}`)
end) end)
return {} return {}

View file

@ -13,12 +13,12 @@ local React = require("./React")
@param dependencies An array of values used to memoize the getter. @param dependencies An array of values used to memoize the getter.
@return The current state. @return The current state.
]] ]]
local function useSignalState<T>(getter: () -> T, dependencies: { any }?): T local function useSignalState<T>(getter: () -> T, dependencies: {}?): T
local value, setValue = React.useState(getter) local value, setValue = React.useState(getter)
React.useLayoutEffect(function() React.useLayoutEffect(function()
return Charm.listen(getter, setValue) return Charm.listen(getter, setValue :: (T, T?) -> ())
end, dependencies or {}) end, dependencies :: { any } or {})
return value return value
end end
@ -34,13 +34,13 @@ end
@param dependencies An array of values used to memoize the getter. @param dependencies An array of values used to memoize the getter.
@return A binding that stores the current state. @return A binding that stores the current state.
]] ]]
local function useSignalBinding<T>(getter: () -> T, dependencies: { any }?) local function useSignalBinding<T>(getter: () -> T, dependencies: {}?)
local initialValue = React.useMemo(getter, {}) local initialValue = React.useMemo(getter, {})
local binding, setBinding = React.useBinding(initialValue) local binding, setBinding = React.useBinding(initialValue)
React.useLayoutEffect(function() React.useLayoutEffect(function()
return Charm.listen(getter, setBinding) return Charm.listen(getter, setBinding :: (T, T?) -> ())
end, dependencies or {}) end, dependencies :: { any } or {})
return binding return binding
end end

View file

@ -13,12 +13,13 @@ local function renderHook<Props, Result>(callback: (Props) -> Result, props: Pro
local function Component(props: Props) local function Component(props: Props)
hook.result = callback(props) hook.result = callback(props)
return nil
end end
function hook.rerender(newProps: Props?) function hook.rerender(newProps: Props?)
props = newProps or props props = newProps or props
ReactRoblox.act(function() ReactRoblox.act(function()
root:render(React.createElement(Component, props)) root:render(React.createElement(Component, props) :: any)
end) end)
end end

View file

@ -1,5 +1,6 @@
local test = require("../../tests/test") local test = require("../../tests/test")
-- selene: allow(global_usage)
_G.__ROACT_17_MOCK_SCHEDULER__ = true _G.__ROACT_17_MOCK_SCHEDULER__ = true
test.describe("useSignalState", function() test.describe("useSignalState", function()

View file

@ -11,7 +11,7 @@ local Vide = require("./Vide")
local function useSignalState<State>(getter: () -> State): () -> State local function useSignalState<State>(getter: () -> State): () -> State
local state = Vide.source(getter()) local state = Vide.source(getter())
Vide.cleanup(Charm.listen(getter, state)) Vide.cleanup(Charm.listen(getter, state :: (State, State?) -> ()))
return state return state
end end

View file

@ -5,7 +5,7 @@ local test = require("../../../tests/test")
test("tracks signal state", function() test("tracks signal state", function()
local getState, setState = Charm.signal(0) local getState, setState = Charm.signal(0)
local source local source: () -> number
local cleanup = Vide.root(function() local cleanup = Vide.root(function()
source = VideCharm.useSignalState(getState) source = VideCharm.useSignalState(getState)
end) end)
@ -17,7 +17,7 @@ end)
test("accepts a selector", function() test("accepts a selector", function()
local getState, setState = Charm.signal(0) local getState, setState = Charm.signal(0)
local source local source: () -> number
local cleanup = Vide.root(function() local cleanup = Vide.root(function()
source = VideCharm.useSignalState(function() source = VideCharm.useSignalState(function()
return 2 * getState() return 2 * getState()

View file

@ -2,6 +2,4 @@ std = "roblox"
exclude = ["*/node_modules/*"] exclude = ["*/node_modules/*"]
[lints] [lints]
global_usage = "allow"
shadowing = "allow" shadowing = "allow"
incorrect_standard_library_use = "allow"

View file

@ -1 +0,0 @@
(require)("@self/run.server")