mirror of
https://github.com/littensy/charm.git
synced 2026-10-22 21:01:38 +00:00
refactor: reorganize repo, create deep-charm
This commit is contained in:
parent
d741640d28
commit
89478f679c
64 changed files with 913 additions and 744 deletions
9
packages/deep-charm/Charm.luau
Normal file
9
packages/deep-charm/Charm.luau
Normal 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
|
||||
6
packages/deep-charm/default.project.json
Normal file
6
packages/deep-charm/default.project.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "deep-charm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
27
packages/deep-charm/package.json
Normal file
27
packages/deep-charm/package.json
Normal 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
53
packages/deep-charm/src/index.d.ts
vendored
Normal 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;
|
||||
165
packages/deep-charm/src/init.luau
Normal file
165
packages/deep-charm/src/init.luau
Normal 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,
|
||||
}
|
||||
79
packages/deep-charm/test/gc.test.luau
Normal file
79
packages/deep-charm/test/gc.test.luau
Normal 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 {}
|
||||
11
packages/deep-charm/test/init.luau
Normal file
11
packages/deep-charm/test/init.luau
Normal 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 {}
|
||||
189
packages/deep-charm/test/reactive.test.luau
Normal file
189
packages/deep-charm/test/reactive.test.luau
Normal 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 {}
|
||||
9
packages/deep-charm/wally.toml
Normal file
9
packages/deep-charm/wally.toml
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue