refactor: reorganize repo, create deep-charm

This commit is contained in:
littensy 2026-02-24 19:49:34 -08:00
parent d741640d28
commit 89478f679c
64 changed files with 913 additions and 744 deletions

View file

@ -0,0 +1,9 @@
if not script then
return require("../charm/src")
end
local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T>
return Charm

View file

@ -0,0 +1,6 @@
{
"name": "deep-charm",
"tree": {
"$path": "src"
}
}

View file

@ -0,0 +1,27 @@
{
"name": "@rbxts/deep-charm",
"version": "0.1.0-rc.1",
"description": "Use Charm with plain Luau tables",
"license": "MIT",
"main": "src/init.luau",
"types": "src/index.d.ts",
"files": [
"src",
"*.luau",
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!test"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {
"@rbxts/charm": "workspace:^"
}
}

53
packages/deep-charm/src/index.d.ts vendored Normal file
View file

@ -0,0 +1,53 @@
export type ReactiveProxy<T extends object = object> = T & {
__track: () => void;
__target: T;
__signals: Map<unknown, () => unknown>;
};
/**
* Creates a deep reactive value that tracks properties when they're accessed.
* Returns a proxy object that subscribes to changes on any nested property
* you read, and a function for mutating the original table.
*
* @example
* ```luau
* local users, updateUsers = reactive({
* user = { name = "John", surname = "Doe" },
* })
*
* effect(function()
* print(users.user and `{users.user.name} {users.user.surname}`)
* end)
*
* updateUsers(function(state)
* state.user.name = "Jane"
* state.user.surname = "Smith"
* end)
* ```
*
* @param initialValue The value to make deeply reactive.
* @returns A proxy table and a function to mutate the original table.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/
export function reactive<T extends object>(
initialValue: T,
): LuaTuple<[value: T, update: (update: T | ((initialValue: T) => void)) => T]>;
/**
* Determines if the provided value is a reactive proxy created by `reactive()`.
*
* @param value A value to test.
* @returns `true` if the value is a reactive proxy, `false` otherwise.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/
export function isReactive(value: unknown): boolean;
/**
* Unwraps a reactive proxy to get the original table. If the provided value
* is not a proxy, it is returned as-is.
*
* @param value A reactive proxy or a non-proxy value.
* @returns The unwrapped target value.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/
export function toRaw<T>(value: T): T;

View file

@ -0,0 +1,165 @@
local Charm = require("./Charm")
local computed = Charm.computed
local trigger = Charm.trigger
local getActiveSub = Charm.getActiveSub
export type ReactiveProxy<T = any> = typeof(setmetatable(
{} :: {
__track: () -> (),
__target: T,
__signals: { [unknown]: () -> unknown },
},
{} :: { __index: T }
))
local ReactiveProxy = {
proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }),
}
--[[
Determines if the provided value is a reactive proxy created by `reactive()`.
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function isReactive(value: any): boolean
return type(value) == "table" and getmetatable(value) == ReactiveProxy
end
--[[
Unwraps a reactive proxy to get the original table. If the provided value
is not a proxy, it is returned as-is.
@param value A reactive proxy or a non-proxy value
@return The source table if `value` is a reactive proxy
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function toRaw<T>(value: T): T
if isReactive(value) then
local value = value :: ReactiveProxy<T> & T
value.__track()
return toRaw(value.__target)
end
return value
end
--[[
Creates a deep reactive value that tracks properties when they're accessed.
Returns a proxy object that subscribes to changes on any nested property
you read, and a function for mutating the original table.
@example
```luau
local users, updateUsers = reactive({
{ name = "John", surname = "Doe" },
})
effect(function()
print(`{users[1].name} {users[1].surname}`)
end)
updateUsers(function(state)
state[1].name = "Jane"
state[1].surname = "Smith"
end)
```
@param initialValue Source table to make deeply reactive
@return A proxy table that tracks reads to nested properties
@return A function for batching mutations to the source table
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function reactive<T>(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ())
initialValue = toRaw(initialValue)
local proxy = ReactiveProxy.proxyMap[initialValue]
if not proxy then
local track = computed(function(version: number?)
-- Reference the proxy to avoid GCing while an effect is actively
-- tracking this reactive object.
local _ref = proxy
-- Allow updates from the parent to propagate down when the
-- target is mutated directly through the setter.
if trackParent then
trackParent()
end
return (version or 0) + 1
end)
proxy = setmetatable({
__target = initialValue,
__signals = setmetatable({}, { __mode = "v" }) :: any,
__track = track,
}, ReactiveProxy)
ReactiveProxy.proxyMap[initialValue] = proxy
end
local function setValue(update: ((T) -> ())?)
if update then
update(proxy.__target)
end
trigger(proxy.__track)
end
return proxy :: any, setValue
end
--[[
Wraps a value in `reactive()` if it is a table; otherwise, returns the
value as-is.
@param value Source value to make deeply reactive
@param trackParent Optional tracking function to propagate deep updates
@return A reactive proxy if the value is a table
]]
local function toReactive<T>(value: T, trackParent: (() -> ())?): T
return if type(value) == "table" then reactive(value, trackParent) else value
end
function ReactiveProxy.__index(self: ReactiveProxy, key: any): any
local track = self.__track
local signals = self.__signals
local target = self.__target
if getActiveSub() then
if not signals[key] then
signals[key] = computed(function()
track()
return target[key]
end)
end
signals[key]()
end
return toReactive(target[key], track)
end
function ReactiveProxy.__newindex(self: ReactiveProxy, key: any, value: any)
value = toRaw(value)
if self.__target[key] ~= value then
self.__target[key] = value
trigger(self.__track)
end
end
function ReactiveProxy.__iter(self: ReactiveProxy)
self.__track()
local function nextReactive(target: any, prevKey: any)
local nextKey, nextValue = next(target, prevKey)
return nextKey, toReactive(nextValue, self.__track)
end
return nextReactive, self.__target
end
function ReactiveProxy.__len(self: ReactiveProxy)
self.__track()
return #self.__target
end
return {
reactive = reactive,
isReactive = isReactive,
toRaw = toRaw,
}

View file

@ -0,0 +1,79 @@
local Charm = require("../Charm")
local DeepCharm = require("../src")
local test = require("../../../tests/test")
type ReactiveProxy<T = any> = DeepCharm.ReactiveProxy<T>
local computed = Charm.computed
local effect = Charm.effect
local reactive = DeepCharm.reactive
local function weakRef<T>(value: T): { value: T? }
return setmetatable({ value = value }, { __mode = "v" }) :: any
end
local function gc()
for _ = 1, 2000 do
local _ = table.create(100, 0)
end
end
test("should release inner reactive proxy", function()
local valueRef = weakRef({})
local src: any = reactive({ value = valueRef.value })
local dispose = effect(function()
local _ = src.value -- create proxy
end)
src.value = nil :: any -- release proxy
dispose()
gc()
assert(src.__signals.value == nil, "should release signals")
assert(valueRef.value == nil, "should release inner proxy")
end)
test("should release reactive property deps", function()
local src: any = reactive({ foo = 1 })
local c = computed(function()
return src.foo
end)
c()
assert(src.__signals.foo ~= nil, "should have deps for foo")
c = nil :: any
gc()
assert(src.__signals.foo == nil, "should release deps for foo")
end)
test("should not release effect for reactive", function()
local src = reactive({ foo = 1 })
local calls = 0
effect(function()
calls += 1
local _ = src.foo
end)
assert(calls == 1, "should call effect once")
gc()
src.foo += 1
assert(calls == 2, "should call effect again")
end)
test("should not release tracked reactive", function()
local src = reactive({ users = {} :: { [string]: {}? } })
local user
effect(function()
user = src.users.user
end)
gc()
src.users.user = {}
assert(user ~= nil, "src.users.user should not be released")
end)
return {}

View file

@ -0,0 +1,11 @@
local test = require("../../tests/test")
test.describe("reactive", function()
require(`@self/reactive.test`)
end)
test.describe("gc", function()
require(`@self/gc.test`)
end)
return {}

View file

@ -0,0 +1,189 @@
local Charm = require("../Charm")
local DeepCharm = require("../src")
local test = require("../../../tests/test")
local effect = Charm.effect
local reactive = DeepCharm.reactive
local toRaw = DeepCharm.toRaw
test("should track nested properties", function()
local src, setSrc = reactive({ a = { b = { c = 0 } } })
local calls = {}
effect(function()
table.insert(calls, `{src.a.b.c}`)
end)
setSrc(function(state)
state.a.b.c = 1
end)
src.a.b.c = 2
src.a.b.c = 2
setSrc()
assert(table.concat(calls) == "012", table.concat(calls))
end)
test("should track object type changes", function()
local src = reactive({ a = { b = 0 } } :: { a: any })
local calls = {}
effect(function()
table.insert(calls, tostring(if type(src.a) == "table" then src.a.b else src.a))
end)
src.a = { b = 0 }
src.a = 1
src.a = { b = 2 }
src.a = nil
src.a = 3
assert(table.concat(calls) == "0012nil3", table.concat(calls))
end)
test("should unwrap proxy assignments", function()
local src = reactive({ a = { x = 0 }, b = { x = 0 } })
local runs = 0
effect(function()
local _, _ = src.a.x, src.b.x
runs += 1
end)
src.a, src.b = src.a, src.b
assert(runs == 1, runs)
src.a, src.b = src.b, src.a
assert(runs == 3, runs)
end)
test("should track iteration", function()
local src, setSrc = reactive({} :: { [string]: number })
local calls = {}
effect(function()
for key, value in src do
table.insert(calls, `{key}={value};`)
end
end)
src.a = 1
assert(table.concat(calls) == "a=1;", table.concat(calls))
table.clear(calls)
src.b = 2
assert(table.concat(calls) == "a=1;b=2;", table.concat(calls))
table.clear(calls)
src.a = 2
assert(table.concat(calls) == "a=2;b=2;", table.concat(calls))
table.clear(calls)
setSrc()
assert(table.concat(calls) == "a=2;b=2;", table.concat(calls))
end)
test("should track iteration for nested tables", function()
local src = reactive({} :: { [string]: { value: number } })
local calls = {}
effect(function()
for key, obj in src do
table.insert(calls, `{key}={obj.value};`)
end
end)
src.a = { value = 1 }
table.clear(calls)
src.b = { value = 1 }
assert(table.concat(calls) == "a=1;b=1;", table.concat(calls))
table.clear(calls)
src.a.value = 2
assert(table.concat(calls) == "a=2;b=1;", table.concat(calls))
end)
test("should track array length", function()
local src = reactive({ array = {} :: { number } })
local calls = {}
effect(function()
table.insert(calls, #src.array)
end)
src.array[#src.array + 1] = 1
src.array[#src.array + 1] = 2
src.array[#src.array + 1] = 3
src.array = { 1, 2, 3, 4 }
assert(table.concat(calls) == "01234", table.concat(calls))
end)
test("should not track unread properties", function()
local src = reactive({ value = { a = 1, b = 2 } })
local calls = 0
effect(function()
local _ = src.value.a
calls += 1
end)
assert(calls == 1, calls)
src.value.b = 3
assert(calls == 1, calls)
end)
test("should overwrite with table passed to setter", function()
local src, setSrc = reactive({ a = 1, b = 2, c = 3 } :: { [string]: number? })
local calls = {}
effect(function()
table.insert(calls, `{src.a}{src.b}{src.c}`)
end)
setSrc(function(src)
src.a = 2
src.b = nil
src.c = 4
end)
assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ","))
end)
test("should unwrap proxy", function()
local raw = {}
local src = reactive(raw)
local runs = 0
effect(function()
local _ = toRaw(src)
runs += 1
end)
assert(toRaw(src) == raw, "unwrap did not return the original table")
assert(toRaw(raw) == raw, "unwrap modified a non-proxy value")
assert(runs == 1, runs)
src.x = 1
assert(runs == 2, runs)
end)
test("should unwrap nested proxy", function()
local raw = {}
local inner = reactive(raw)
local src = reactive(inner)
local runs = 0
effect(function()
local _ = toRaw(src)
runs += 1
end)
assert(toRaw(src) == raw, "unwrap did not return the original table")
assert(runs == 1, runs)
inner.x = 1
assert(runs == 2, runs)
src.y = 2
assert(runs == 3, runs)
end)
return {}

View file

@ -0,0 +1,9 @@
[package]
name = "littensy/deep-charm"
version = "0.1.0-rc.1"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = ["node_modules", "test", "src/index.d.ts", "package.json"]
[dependencies]
Charm = "littensy/charm@^0.11.0-rc.3"