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

@ -5,6 +5,5 @@ end
local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T>
export type ReactiveProxy<T = any> = Charm.ReactiveProxy<T>
return Charm

View file

@ -11,14 +11,14 @@
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!**/__tests__"
"!test"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {

View file

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

View file

@ -4,8 +4,6 @@ local patch = require("./patch")
local types = require("./types")
local utils = require("./utils")
type ReactiveProxy<T = {}> = Charm.ReactiveProxy<T>
type SyncPayload = types.SyncPayload
type SyncStatus = number
@ -36,9 +34,21 @@ local clients: { [any]: ClientKeys } = {}
local connection: RBXScriptConnection?
local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())?
--[[
Manually converts a reactive proxy to its raw representation to potentially
support custom implementations of reactive proxies.
]]
local function toRaw<T>(value: T): T
if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then
value.__track()
return toRaw(value.__target)
end
return value
end
local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown)
if trackedStates[key] then
-- Ignore duplicates, but warn against name collisions
-- Warn against name collisions with conflicting signals or proxies
if trackedStates[key].identifier ~= identifier then
warn("[Charm] Attempted to sync different states with the same key:", key)
end
@ -47,12 +57,12 @@ local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown)
local trackedState: TrackedState<T>
local initialValue = getter()
local rawInitialValue = Charm.toRaw(initialValue)
local rawInitialValue = toRaw(initialValue)
local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue
local dispose = Charm.effect(function()
local newValue = getter()
local rawValue = Charm.toRaw(newValue)
local rawValue = toRaw(newValue)
local isReactive = newValue ~= rawValue
if not trackedState then

View file

@ -18,4 +18,13 @@ export type SyncPayload =
]]
export type None = { __none: "__none" }
export type ReactiveProxy<T = any> = typeof(setmetatable(
{} :: {
__track: () -> (),
__target: T,
__signals: { [unknown]: () -> unknown },
},
{} :: { __index: T }
))
return {}

View file

@ -1,4 +1,5 @@
local Charm = require("../Charm")
local DeepCharm = require("../../deep-charm/src")
local client = require("../src/client")
local test = require("../../../tests/test")
@ -38,7 +39,7 @@ test("should merge patch", function()
end)
test("should merge patch (reactive)", function()
local a = Charm.reactive({ b = 1, c = 2 })
local a = DeepCharm.reactive({ b = 1, c = 2 })
local runs = 0
Charm.effect(function()

View file

@ -1,4 +1,5 @@
local Charm = require("../Charm")
local DeepCharm = require("../../deep-charm/src")
local client = require("../src/client")
local config = require("../src/config")
local server = require("../src/server")
@ -13,9 +14,9 @@ end)
test("should replicate server state to client", function()
local target = {}
local serverA = Charm.atom({ count = 0 })
local serverB = Charm.reactive({ count = 0 })
local serverB = DeepCharm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 })
local clientB = Charm.reactive({ count = -1 })
local clientB = DeepCharm.reactive({ count = -1 })
client.addSignals({ a = clientA, b = clientB })
server.addSignalsToClient(target, { a = serverA, b = serverB })
@ -50,9 +51,9 @@ end)
test("should replicate historical server state to client", function()
local target = {}
local serverA = Charm.atom({ count = 0 })
local serverB = Charm.reactive({ count = 0 })
local serverB = DeepCharm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 })
local clientB = Charm.reactive({ count = -1 })
local clientB = DeepCharm.reactive({ count = -1 })
config.preserveHistory = true

View file

@ -1,4 +1,5 @@
local Charm = require("../Charm")
local DeepCharm = require("../../deep-charm/src")
local config = require("../src/config")
local patch = require("../src/patch")
local server = require("../src/server")
@ -66,7 +67,7 @@ end)
test("should replicate partial state patches (reactive)", function()
local client = {}
local a = Charm.reactive({ x = { y = 0 }, z = 0 :: number? })
local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? })
local target, payloads
server.addSignalsToClient(client, { a = a })
@ -99,7 +100,7 @@ end)
test("should handle nested proxies", function()
local client = {}
local src = Charm.reactive({ (Charm.reactive({})) })
local src = DeepCharm.reactive({ (DeepCharm.reactive({})) })
local payloads: {}? = nil
server.addSignalsToClient(client, { src = src })
@ -288,9 +289,9 @@ test.describe("historical", function()
test("should replicate granular state changes (reactive)", function()
local client = {}
local a = Charm.reactive({ value = 0 })
local b = Charm.reactive({ value = 0 })
local c = Charm.reactive({ value = 0 })
local a = DeepCharm.reactive({ value = 0 })
local b = DeepCharm.reactive({ value = 0 })
local c = DeepCharm.reactive({ value = 0 })
local target, payloads
server.addSignalsToClient(client, { a = a, b = b, c = c })

View file

@ -5,7 +5,7 @@ registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = [
"node_modules",
"__tests__",
"test",
"src/index.d.ts",
"package.json",
"Charm.luau",

View file

@ -11,14 +11,14 @@
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!**/__tests__"
"!test"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish"
}
}

View file

@ -272,54 +272,6 @@ export function observe<V, K extends Key>(
fn: (value: V, key: K) => Cleanup | void,
): Cleanup;
/**
* 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, setUsers = reactive({
* user = { name = "John", surname = "Doe" },
* })
*
* effect(function()
* print(users.user and `{users.user.name} {users.user.surname}`)
* end)
*
* setUsers(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, setter: (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;
/**
* Returns the currently active subscriber (effect or computed node).
*

View file

@ -9,19 +9,17 @@ export type Link = system.Link
export type ReactiveFlags = system.ReactiveFlags
export type ReactiveNode = system.ReactiveNode
local link = system.link
local unlink = system.unlink
local propagate = system.propagate
local checkDirty = system.checkDirty
local shallowPropagate = system.shallowPropagate
local createReactiveSystem = system.createReactiveSystem
type Cleanup = () -> ()
export type Atom<T = any> = (...((T) -> T) | T) -> T
export type ReactiveProxy<T = any> = typeof(setmetatable(
{} :: {
__track: () -> (),
__target: T,
__signals: { [unknown]: () -> unknown },
},
{} :: { __index: T }
))
type EffectScopeNode = ReactiveNode & {
cleanups: { Cleanup },
}
@ -41,36 +39,6 @@ type SignalNode<T = any> = ReactiveNode & {
pendingValue: T,
}
local function inlineTest(): string
return debug.info(1, "n")
end
local IS_O2 = inlineTest() ~= "inlineTest"
local flags = {
--[[
Enforces synchronous, non-yielding behavior in signals and effects.
Also enables state validation in Charm Sync to catch sync errors early.
Enabled if the optimization level is below 2, which is true in studio.
]]
strict = not IS_O2,
--[[
Enforces immutability of tables by deep-freezing table values. Enabled
if the optimization level is below 2, which is true in studio.
]]
frozen = not IS_O2,
--[[
Whether parent effects should track inner effects and clean them up
when the parent effect re-runs. This is usually desirable, but can be
disabled to revert to the old behavior. Defaults to `true`.
]]
trackInnerEffects = true,
}
local ReactiveProxy = {
proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }),
}
-- Bitmask flags for tracking the state of reactive nodes. Flags are combined
-- and checked using bitwise operations for efficiency.
local NONE = 0b0000000 -- Default state
@ -81,13 +49,6 @@ local DIRTY = 0b0010000 -- Value has changed and needs updating
local PENDING = 0b0100000 -- Might be dirty, pending verification
local PEEKING = 0b1000000 -- Blocking dependency tracking
-- Alien Signals reactive system
local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> ()
local unlink: (link: Link, sub: ReactiveNode?) -> Link?
local propagate: (link: Link) -> ()
local checkDirty: (link: Link, sub: ReactiveNode) -> boolean
local shallowPropagate: (link: Link) -> ()
-- Global version counter for dependency link deduplication. Incremented at the
-- start of each effect/computed run to ensure links created in the same cycle
-- are deduplicated.
@ -104,6 +65,30 @@ local batchDepth = 0
-- Dependencies accessed during this time will link to the active subscriber.
local activeSub: ReactiveNode?
local function isO2(): boolean
return debug.info(1, "n") ~= "isO2"
end
local flags = {
--[[
Enforces synchronous, non-yielding behavior in signals and effects.
Also enables state validation in Charm Sync to catch sync errors early.
Enabled if the optimization level is below 2, which is true in studio.
]]
strict = not isO2(),
--[[
Enforces immutability of tables by deep-freezing table values. Enabled
if the optimization level is below 2, which is true in studio.
]]
frozen = not isO2(),
--[[
Whether parent effects should track inner effects and clean them up
when the parent effect re-runs. This is usually desirable, but can be
disabled to revert to the old behavior. Defaults to `true`.
]]
trackInnerEffects = true,
}
--[[
Wraps a function to prevent yielding when in strict mode. If the function
attempts to yield, an error is thrown.
@ -191,8 +176,8 @@ end
]]
local function runCleanups(cleanups: { Cleanup })
for key, cleanup in cleanups do
cleanups[key] = nil
local prevSub = setActiveSub(nil)
cleanups[key] = nil
xpcall(cleanup, function(err)
warn("Error occurred during effect cleanup:", debug.traceback(tostring(err), 2))
end)
@ -325,69 +310,122 @@ local function clearPending(sub: ReactiveNode, flags: ReactiveFlags)
end
--[[
Returns a function that evaluates the computed value when accessed.
Computed values are lazily evaluated and cached unless marked dirty or
pending with dirty dependencies.
Performs a thorough cleanup of the given effect or effect scope:
@param this Computed node
@return Operator to read the computed value
1. Removes all dependency links
2. Unlinks from parent effects/scopes
3. Clears state flags to mark the effect as inactive
4. Runs all registered cleanup functions
@param this Effect node
]]
local function computedOper<T>(this: ComputedNode<T>): () -> T
local function readComputed(): T
local flags = this.flags
local function stopEffect(this: EffectScopeNode)
this.depsTail = nil
this.flags = NONE
if
bit32.btest(flags, DIRTY)
or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or clearPending(this, flags)))
then
if updateComputed(this) then
-- Notify subscribers if the value changed
local subs = this.subs
if subs then
shallowPropagate(subs)
end
end
elseif flags == NONE then
-- First access should initialize the computed value
this.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
purgeDeps(this)
local prevSub = setActiveSub(this)
local success, result = pcall(this.getter)
activeSub = prevSub
this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK))
if success then
this.value = result
else
error(result)
end
end
if activeSub then
link(this, activeSub, cycle)
end
return this.value :: T
local sub = this.subs
if sub then
unlink(sub, this)
end
return readComputed
runCleanups(this.cleanups)
end
--[[
Returns functions to read and write a signal's value:
- Setter: Updates the signal's value and notifies subscribers
- Getter: Returns the current value and registers dependency
Updates a signal or computed value and returns whether the value changed.
@param this Signal node
@param equals Optional comparator function to check for value changes
@return Operator to get the signal value
@return Operator to set the signal value
@param node Signal or computed node
@return Boolean indicating whether the value changed
]]
local function signalOper<T>(
this: SignalNode<T>,
local function update(node: ReactiveNode): boolean
if node.depsTail then
return updateComputed(node :: ComputedNode)
else
return updateSignal(node :: SignalNode)
end
end
--[[
Queues an effect for execution, ensuring inner effects are notified in the
correct order.
@param effect Effect node to notify
]]
local function notify(effect: ReactiveNode)
local insertIndex = queuedLength
local firstInsertedIndex = insertIndex
-- Collect inner effects in a chain
repeat
queued[insertIndex] = effect :: EffectNode
insertIndex += 1
effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING))
effect = (effect.subs and effect.subs.sub) :: EffectNode
if not effect or bit32.band(effect.flags, WATCHING) == 0 then
break
end
until false
queuedLength = insertIndex
-- Reverse the collected effects to ensure outer effects run first
while true do
insertIndex -= 1
if firstInsertedIndex >= insertIndex then
break
end
queued[firstInsertedIndex], queued[insertIndex] = queued[insertIndex], queued[firstInsertedIndex]
firstInsertedIndex += 1
end
end
--[[
Called when a node is not being watched by any subscribers. Cleans up the
node's dependencies and performs the necessary cleanup operations:
- Computed signals: Unlink dependencies and mark as dirty for potential future updates
- Effects/scopes: Perform full cleanup to free resources
@param node Reactive node (computed or effect/scope)
]]
local function unwatched(node: ReactiveNode)
if bit32.band(node.flags, MUTABLE) == 0 then
stopEffect(node :: EffectScopeNode)
elseif node.depsTail then
node.depsTail = nil
node.flags = bit32.bor(MUTABLE, DIRTY)
purgeDeps(node)
end
end
--[[
Creates a reactive signal that stores a value and notifies subscribers when
the value changes. The signal provides both a getter and a setter.
@param initialValue Initial value for the signal
@param equals Optional comparator function to check for value changes
@return Getter function to read the signal value
@return Setter function to update the signal value
@see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
]]
local function signal<T>(
initialValue: T,
equals: ((current: T, incoming: T) -> boolean)?
): (() -> T, (((T) -> T) | T) -> T)
local this: SignalNode = {
currentValue = initialValue,
pendingValue = initialValue,
flags = MUTABLE,
}
if flags.frozen then
deepFreeze(initialValue)
end
local function writeSignal(update: ((T) -> T) | T): T
local value: T
@ -458,122 +496,6 @@ local function signalOper<T>(
return readSignal, writeSignal
end
--[[
Returns an effect cleanup operator that disposes of the effect. This
operator performs a thorough cleanup:
1. Removes all dependency links
2. Unlinks from parent effects/scopes
3. Clears state flags to mark the effect as inactive
4. Runs all registered cleanup functions
@param this Effect node
@return Effect cleanup operator
]]
local function effectOper(this: EffectScopeNode): Cleanup
return function()
this.depsTail = nil
this.flags = NONE
purgeDeps(this)
local sub = this.subs
if sub then
unlink(sub, this)
end
runCleanups(this.cleanups)
end
end
--[[
Updates a signal or computed value and returns whether the value changed.
@param node Signal or computed node
@return Boolean indicating whether the value changed
]]
local function update(node: ReactiveNode): boolean
if node.depsTail then
return updateComputed(node :: ComputedNode)
else
return updateSignal(node :: SignalNode)
end
end
--[[
Queues an effect for execution, ensuring inner effects are notified in the
correct order.
@param effect Effect node to notify
]]
local function notify(effect: ReactiveNode)
local insertIndex = queuedLength
local firstInsertedIndex = insertIndex
-- Collect inner effects in a chain
repeat
effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING))
queued[insertIndex] = effect :: EffectNode
insertIndex += 1
effect = (effect.subs and effect.subs.sub) :: EffectNode
if not effect or bit32.band(effect.flags, WATCHING) == 0 then
break
end
until false
queuedLength = insertIndex
-- Reverse the collected effects to ensure outer effects run first
while true do
insertIndex -= 1
if firstInsertedIndex >= insertIndex then
break
end
queued[firstInsertedIndex], queued[insertIndex] = queued[insertIndex], queued[firstInsertedIndex]
firstInsertedIndex += 1
end
end
--[[
Called when a node is not being watched by any subscribers. Cleans up the
node's dependencies and performs the necessary cleanup operations:
- Computed signals: Unlink dependencies and mark as dirty for potential future updates
- Effects/scopes: Perform full cleanup to free resources
@param node Reactive node (computed or effect/scope)
]]
local function unwatched(node: ReactiveNode)
if bit32.band(node.flags, MUTABLE) == 0 then
effectOper(node :: EffectScopeNode)()
elseif node.depsTail then
node.depsTail = nil
node.flags = bit32.bor(MUTABLE, DIRTY)
purgeDeps(node)
end
end
--[[
Creates a reactive signal that stores a value and notifies subscribers when
the value changes. The signal provides both a getter and a setter.
@param initialValue Initial value for the signal
@param equals Optional comparator function to check for value changes
@return Getter function to read the signal value
@return Setter function to update the signal value
@see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
]]
local function signal<T>(
initialValue: T,
equals: ((current: T, incoming: T) -> boolean)?
): (() -> T, (((T) -> T) | T) -> T)
if flags.frozen then
deepFreeze(initialValue)
end
return signalOper({
currentValue = initialValue,
pendingValue = initialValue,
flags = MUTABLE,
}, equals)
end
--[[
Creates a signal and returns a function that can act as both the getter and
setter. Calling the atom with one argument sets its value, while calling it
@ -601,10 +523,50 @@ end
@see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
]]
local function computed<T>(getter: (previousValue: T?) -> T): () -> T
return computedOper({
local this: ComputedNode = {
flags = NONE,
getter = noYield(getter),
})
}
local function readComputed(): T
local flags = this.flags
if
bit32.btest(flags, DIRTY)
or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or clearPending(this, flags)))
then
if updateComputed(this) then
-- Notify subscribers if the value changed
local subs = this.subs
if subs then
shallowPropagate(subs)
end
end
elseif flags == NONE then
-- First access should initialize the computed value
this.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
local prevSub = setActiveSub(this)
local success, result = pcall(this.getter)
activeSub = prevSub
this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK))
if success then
this.value = result
else
error(result)
end
end
if activeSub then
link(this, activeSub, cycle)
end
return this.value :: T
end
return readComputed
end
--[[
@ -643,7 +605,9 @@ local function effect(fn: () -> ...Cleanup?): Cleanup
table.insert(this.cleanups, noYield(result))
end
return effectOper(this)
return function()
stopEffect(this)
end
end
--[[
@ -675,7 +639,9 @@ local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup
table.insert(this.cleanups, noYield(result))
end
return effectOper(this)
return function()
stopEffect(this)
end
end
--[[
@ -1000,165 +966,17 @@ local function mapped<KI, VI, KO, VO>(getter: () -> { [KI]: VI }, mapper: (item:
end)
end
--[[
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, setUsers = reactive({
{ name = "John", surname = "Doe" },
})
effect(function()
print(`{users[1].name} {users[1].surname}`)
end)
setUsers(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 activeSub 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
link, unlink, propagate, checkDirty, shallowPropagate = system.createReactiveSystem({
update = update,
notify = notify,
unwatched = unwatched,
})
createReactiveSystem(update, notify, unwatched)
return {
-- Core reactive primitives
ReactiveFlags = system.ReactiveFlags,
flags = flags,
atom = atom,
signal = signal,
computed = computed,
effect = effect,
effectScope = effectScope,
trigger = trigger,
-- Charm utilities
flags = flags,
atom = atom,
onCleanup = onCleanup,
untracked = untracked,
peek = peek,
batch = batch,
@ -1169,14 +987,7 @@ return {
getter: () -> { [KI]: VI },
mapper: (item: VI, key: KI) -> (VO, KO)
) -> () -> { [KO]: VO }) & (<K, VI, VO>(getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }),
-- Reactive proxies
reactive = reactive,
isReactive = isReactive,
toRaw = toRaw,
-- Advanced API
ReactiveFlags = system.ReactiveFlags,
onCleanup = onCleanup,
getActiveSub = getActiveSub,
setActiveSub = setActiveSub,
startBatch = startBatch,

View file

@ -37,7 +37,7 @@ local DIRTY = 0b0010000
local PENDING = 0b0100000
local PEEKING = 0b1000000
local ReactiveFlags = {
local ReactiveFlags = table.freeze({
None = NONE,
Mutable = MUTABLE,
Watching = WATCHING,
@ -45,161 +45,166 @@ local ReactiveFlags = {
Recursed = RECURSED,
Dirty = DIRTY,
Pending = PENDING,
}
local function createReactiveSystem(props: {
update: (sub: ReactiveNode) -> boolean,
notify: (sub: ReactiveNode) -> (),
unwatched: (sub: ReactiveNode) -> (),
Peeking = PEEKING,
})
local update = props.update
local notify = props.notify
local unwatched = props.unwatched
local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
local link = sub.depsTail
while link do
if link == checkLink then
return true
end
link = link.prevDep
local update: (sub: ReactiveNode) -> boolean
local notify: (sub: ReactiveNode) -> ()
local unwatched: (sub: ReactiveNode) -> ()
local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
local link = sub.depsTail
while link do
if link == checkLink then
return true
end
return false
link = link.prevDep
end
return false
end
local function link(dep: ReactiveNode, sub: ReactiveNode, version: number)
-- DEVIATION: Do not link signals to peeking subscribers
if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then
return
end
local prevDep = sub.depsTail
if prevDep and prevDep.dep == dep then
return
end
local nextDep = if prevDep then prevDep.nextDep else sub.deps
if nextDep and nextDep.dep == dep then
nextDep.version = version
sub.depsTail = nextDep
return
end
local prevSub = dep.subsTail
if prevSub and prevSub.version == version and prevSub.sub == sub then
return
end
local newLink: Link = {
version = version,
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
local function link(dep: ReactiveNode, sub: ReactiveNode, version: number)
-- DEVIATION: Do not link signals to peeking subscribers
if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then
return
end
local prevDep = sub.depsTail
if prevDep and prevDep.dep == dep then
return
end
local nextDep = if prevDep then prevDep.nextDep else sub.deps
if nextDep and nextDep.dep == dep then
nextDep.version = version
sub.depsTail = nextDep
return
end
local prevSub = dep.subsTail
if prevSub and prevSub.version == version and prevSub.sub == sub then
return
end
local newLink: Link = {
version = version,
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 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
local function propagate(link: Link)
repeat
local sub = link.sub
local flags = sub.flags
if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then
sub.flags = bit32.bor(flags, PENDING)
elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then
flags = NONE
elseif bit32.band(flags, RECURSED_CHECK) == 0 then
sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING)
elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 and isValidLink(link, sub) then
sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING))
flags = bit32.band(flags, MUTABLE)
else
sub.depsTail = prevDep
-- selene: allow(if_same_then_else)
flags = NONE
end
if prevDep then
prevDep.nextDep = nextDep
else
sub.deps = nextDep
if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then
notify(sub)
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)
if bit32.btest(flags, MUTABLE) then
local subSubs = sub.subs
if subSubs then
propagate(subSubs)
end
end
return nextDep
end
local function propagate(link: Link)
repeat
local sub = link.sub
local flags = sub.flags
if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then
sub.flags = bit32.bor(flags, PENDING)
elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then
flags = NONE
elseif bit32.band(flags, RECURSED_CHECK) == 0 then
sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING)
elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 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
link = link.nextSub :: Link
until not link
end
local function shallowPropagate(link: Link)
repeat
local sub = link.sub
local flags = sub.flags
if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then
sub.flags = bit32.bor(flags, DIRTY)
if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then
notify(sub)
end
end
link = link.nextSub :: Link
until not link
end
if bit32.btest(flags, MUTABLE) then
local subSubs = sub.subs
if subSubs then
propagate(subSubs)
local function checkDirty(link: Link, sub: ReactiveNode): boolean
repeat
local dep = link.dep
local depFlags = dep.flags
if bit32.btest(sub.flags, DIRTY) then
return 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
end
link = link.nextSub :: Link
until not link
end
local function shallowPropagate(link: Link)
repeat
local sub = link.sub
local flags = sub.flags
if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then
sub.flags = bit32.bor(flags, DIRTY)
if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then
notify(sub)
end
end
link = link.nextSub :: Link
until not link
end
local function checkDirty(link: Link, sub: ReactiveNode): boolean
repeat
local dep = link.dep
local depFlags = dep.flags
if bit32.btest(sub.flags, DIRTY) then
return true
elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then
end
elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then
if checkDirty(dep.deps :: Link, dep) then
if update(dep) then
local subs = dep.subs :: Link
if subs.nextSub then
@ -207,30 +212,33 @@ local function createReactiveSystem(props: {
end
return true
end
elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then
if checkDirty(dep.deps :: Link, dep) then
if update(dep) then
local subs = dep.subs :: Link
if subs.nextSub then
shallowPropagate(subs)
end
return true
end
else
dep.flags = bit32.band(depFlags, bit32.bnot(PENDING))
end
else
dep.flags = bit32.band(depFlags, bit32.bnot(PENDING))
end
end
link = link.nextDep :: Link
until not link
link = link.nextDep :: Link
until not link
return false
end
return false
end
return link, unlink, propagate, checkDirty, shallowPropagate
local function createReactiveSystem(
updateFn: (sub: ReactiveNode) -> boolean,
notifyFn: (sub: ReactiveNode) -> (),
unwatchedFn: (sub: ReactiveNode) -> ()
)
update = updateFn
notify = notifyFn
unwatched = unwatchedFn
end
return {
ReactiveFlags = ReactiveFlags,
link = link,
unlink = unlink,
propagate = propagate,
checkDirty = checkDirty,
shallowPropagate = shallowPropagate,
createReactiveSystem = createReactiveSystem,
}

View file

@ -1,13 +1,10 @@
local Charm = require("../src")
local test = require("../../../tests/test")
type ReactiveProxy<T = any> = Charm.ReactiveProxy<T>
local signal = Charm.signal
local computed = Charm.computed
local effect = Charm.effect
local effectScope = Charm.effectScope
local reactive = Charm.reactive
local function weakRef<T>(value: T): { value: T? }
return setmetatable({ value = value }, { __mode = "v" }) :: any
@ -65,62 +62,4 @@ test("should not release effect for atom", function()
assert(effectCalls == 2, "should call effect again")
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 = Charm.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

@ -9,7 +9,6 @@ local tests = {
"mapped",
"observe",
"peek",
"reactive",
"signal",
"subscribe",
"topology",

View file

@ -3,6 +3,6 @@ name = "littensy/charm"
version = "0.11.0-rc.4"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = ["node_modules", "__tests__", "src/index.d.ts", "package.json"]
exclude = ["node_modules", "test", "src/index.d.ts", "package.json"]
[dependencies]

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

@ -1,9 +1,10 @@
local Charm = require("../src")
local Charm = require("../Charm")
local DeepCharm = require("../src")
local test = require("../../../tests/test")
local effect = Charm.effect
local reactive = Charm.reactive
local toRaw = Charm.toRaw
local reactive = DeepCharm.reactive
local toRaw = DeepCharm.toRaw
test("should track nested properties", function()
local src, setSrc = reactive({ a = { b = { c = 0 } } })

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"

View file

@ -11,14 +11,14 @@
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!**/__tests__"
"!test"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {

View file

@ -51,8 +51,8 @@ local function useSignalBinding<T>(getter: () -> T, dependencies: { any }?)
return binding
end
return {
return table.freeze({
useSignalState = useSignalState,
useSignalBinding = useSignalBinding,
useAtom = useSignalState,
}
})

View file

@ -5,7 +5,7 @@ registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = [
"node_modules",
"__tests__",
"test",
"src/index.d.ts",
"package.json",
"React.luau",

View file

@ -11,14 +11,14 @@
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!**/__tests__"
"!test"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {

View file

@ -5,7 +5,7 @@ registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = [
"node_modules",
"__tests__",
"test",
"src/index.d.ts",
"package.json",
"Vide.luau",