mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 } = {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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 })
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
packages/charm/src/index.d.ts
vendored
48
packages/charm/src/index.d.ts
vendored
|
|
@ -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).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
@ -9,7 +9,6 @@ local tests = {
|
|||
"mapped",
|
||||
"observe",
|
||||
"peek",
|
||||
"reactive",
|
||||
"signal",
|
||||
"subscribe",
|
||||
"topology",
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
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 {}
|
||||
|
|
@ -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 } } })
|
||||
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"
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue