mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
feat: add toRaw()
This commit is contained in:
parent
f082956ed9
commit
9f66803f04
13 changed files with 297 additions and 198 deletions
104
README.md
104
README.md
|
|
@ -34,6 +34,8 @@
|
|||
- https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity
|
||||
- https://angular.dev/guide/signals
|
||||
|
||||
[Migrating from an older version?](#migration)
|
||||
|
||||
<details>
|
||||
<summary><b>Table of Contents</b></summary>
|
||||
|
||||
|
|
@ -42,6 +44,7 @@
|
|||
- [`signal(initialValue, equals?)`](#signalinitialvalue-equals)
|
||||
- [`computed(getter)`](#computedgetter)
|
||||
- [`effect(callback)`](#effectcallback)
|
||||
- [`reactive(initialValue)`](#reactiveinitialvalue)
|
||||
- [`untracked(callback)`](#untrackedcallback)
|
||||
- [`peek(callback)`](#peekcallback)
|
||||
- [`batch(callback)`](#batchcallback)
|
||||
|
|
@ -54,7 +57,6 @@
|
|||
- [`atom(initialValue, equals?)`](#atominitialvalue-equals)
|
||||
- [`recursive()`](#recursive)
|
||||
- [`trigger(callback)`](#triggercallback)
|
||||
- [`reactive(initialValue)`](#reactiveinitialvalue)
|
||||
- [`flags`](#flags)
|
||||
- [Client-Server Sync](#client-server-sync)
|
||||
- [Installation](#installation-1)
|
||||
|
|
@ -276,9 +278,50 @@ setCounter(2) -- Outer: 2, Inner: 2
|
|||
|
||||
---
|
||||
|
||||
### `reactive(initialValue)`
|
||||
|
||||
By default, Charm's signals are _shallowly reactive_, meaning only the value itself is reactive, and table properties are not tracked. Signal values must also be **immutable** in order to track changes in tables.
|
||||
|
||||
You can opt-in to deep reactivity with **reactive proxies**. The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and table values will also be wrapped in a reactive proxy.
|
||||
|
||||
```luau
|
||||
local users, setUsers = reactive({
|
||||
{ name = "John", surname = "Doe" },
|
||||
})
|
||||
|
||||
-- Output: John Doe
|
||||
effect(function()
|
||||
print(`{users[1].name} {users[1].surname}`)
|
||||
end)
|
||||
|
||||
-- Output: Jane Smith
|
||||
setUsers(function(state)
|
||||
state[1].name = "Jane"
|
||||
state[1].surname = "Smith"
|
||||
table.insert(state, { name = "Steve", surname = "Doe" })
|
||||
end)
|
||||
|
||||
-- You can also mutate the reactive proxy directly:
|
||||
users[1].name = "John" -- John Smith
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Because reactive proxies use metatables for reading and writing, functions like `table.insert` will not work on the proxy. Table functions should only be called on the raw table value.
|
||||
|
||||
If you need to access the raw table through the reactive proxy, use the `toRaw()` function:
|
||||
|
||||
```luau
|
||||
local raw = {}
|
||||
local proxy = reactive(raw)
|
||||
|
||||
print(toRaw(proxy) == raw) -- true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `untracked(callback)`
|
||||
|
||||
In case you want to read signals but don't want to subscribe to them, you can use `untracked()` to essentially run code _outside_ the current effect, preventing signals and effects in the callback from being tracked.
|
||||
In case you want to opt-out of dependency tracking in an effect, you can use `untracked()` to call a function _outside_ the current scope, preventing signals and effects in the function from being tracked.
|
||||
|
||||
```luau
|
||||
local getTracked, setTracked = signal(0)
|
||||
|
|
@ -294,7 +337,7 @@ setUntracked(1) -- No output
|
|||
setTracked(2) -- Tracked: 2, Untracked: 1
|
||||
```
|
||||
|
||||
Because `untracked()` executes the callback outside the current effect, nested effects created in the callback do not get tracked by the parent effect:
|
||||
Because `untracked()` executes the callback outside the current effect, nested effects created during the execution of the callback will not be tracked by the parent effect:
|
||||
|
||||
```luau
|
||||
local stopEffect
|
||||
|
|
@ -602,45 +645,6 @@ print(total()) -- 2
|
|||
|
||||
---
|
||||
|
||||
### `reactive(initialValue)`
|
||||
|
||||
The `reactive()` function creates a mutable reactive object that subscribes to only the properties you read. It also returns a setter function you can use to mutate the original table:
|
||||
|
||||
```luau
|
||||
local users, setUsers = reactive({
|
||||
user = { name = "John", surname = "Doe" },
|
||||
})
|
||||
|
||||
-- Output: John Doe
|
||||
effect(function()
|
||||
print(`{users.user.name} {users.user.surname}`)
|
||||
end)
|
||||
|
||||
-- Output: Jane Smith
|
||||
setUsers(function(state)
|
||||
state.user.name = "Jane"
|
||||
state.user.surname = "Smith"
|
||||
end)
|
||||
```
|
||||
|
||||
You can also set properties on the reactive object directly:
|
||||
|
||||
```luau
|
||||
local id = reactive({ name = "John", surname = "Doe" })
|
||||
|
||||
effect(function()
|
||||
print(`{id.name} {id.surname}`)
|
||||
end)
|
||||
|
||||
id.name = "Jane" -- Jane Doe
|
||||
id.surname = "Smith" -- Jane Smith
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Be careful when mutating the reactive object! Because it's a proxy, functions like `table.insert` will not work. Table functions should be called on the original table, which is provided by the setter function.
|
||||
|
||||
---
|
||||
|
||||
### `flags`
|
||||
|
||||
Charm exposes the following global flags to customize behavior:
|
||||
|
|
@ -948,7 +952,7 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that
|
|||
- Instead of creating client/server syncers, these modules now act like singletons. Sync APIs are called directly through `CharmSync.client`/`server`.
|
||||
- [Read the updated docs for syncing state →](#client-server-sync)
|
||||
|
||||
3. The [`strict` and `frozen` flags](#flags) are automatically enabled in Roblox Studio, so unsafe Charm code will start throwing errors if you didn't use the old `__DEV__` flag. The flags have the following behavior:
|
||||
3. The [`strict` and `frozen` flags](#flags) are automatically enabled in Roblox Studio, so unsafe Charm code will start throwing errors. The flags have the following behaviors:
|
||||
- `strict`: Yielding in effects, signals, and other critical Charm functions will throw an error
|
||||
- `frozen`: Tables passed to signals are deeply frozen to strictly enforce data immutability and prevent accidental mutations
|
||||
|
||||
|
|
@ -966,14 +970,12 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that
|
|||
- Recursive checks are opt-out. You can allow recursion for a specific effect by calling [`recursive()`](#recursive) at the top of the effect callback.
|
||||
|
||||
6. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)!
|
||||
- Added [`signal()`](#signalinitialvalue-equals) to make reads and writes more explicit
|
||||
- Added [`listen()`](#listengetter-callback) to create a subscription that runs once immediately
|
||||
- Added [`effectScope()`](#effectscopecallback) for collecting and cleaning up multiple effects at once
|
||||
- Added [`onCleanup()`](#oncleanupcallback-failsilently) to bind a cleanup function to the active effect
|
||||
- Added [`trigger()`](#triggercallback) to trigger updates for table mutations
|
||||
- The [`computed()`](#computedgetter) callback now gets called with the previous computed value
|
||||
- Optimized `computed()` to use lazy evaluation instead of eager updates
|
||||
- Optimized `mapped()` to only map values that changed in the original table
|
||||
- [`reactive()`](#reactiveinitialvalue): make mutable tables deeply reactive
|
||||
- [`signal()`](#signalinitialvalue-equals): make reads and writes more explicit
|
||||
- [`listen()`](#listengetter-callback): create a subscription that runs once immediately
|
||||
- [`effectScope()`](#effectscopecallback): collect and clean up multiple effects at once
|
||||
- [`onCleanup()`](#oncleanupcallback-failsilently): bind a cleanup function to the active effect
|
||||
- [`trigger()`](#triggercallback): trigger updates for table mutations
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -5,5 +5,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ local suite = require("../../../../test/suite")
|
|||
local test = suite.test
|
||||
local afterEach = suite.afterEach
|
||||
|
||||
local function mockArraySerdes(object: { [any]: any }): { [any]: any }
|
||||
local function mockArraySerdes(object: {}): {}
|
||||
if object[1] then
|
||||
local copy = {}
|
||||
for index = 1, #object do
|
||||
|
|
|
|||
|
|
@ -2,13 +2,11 @@ local Charm = require("../Charm")
|
|||
local patch = require("./patch")
|
||||
local types = require("./types")
|
||||
|
||||
type ReactiveProxy<T = {}> = Charm.ReactiveProxy<T>
|
||||
type SyncPayload = types.SyncPayload
|
||||
|
||||
type SignalSetter<T = any> = (update: ((T) -> T) | T) -> ()
|
||||
|
||||
type ReactiveSetter<T = any> = (update: ((T) -> ()) | T) -> ()
|
||||
|
||||
type Reactive<T = any> = typeof(setmetatable({}, {} :: { setValue: ReactiveSetter<T> }))
|
||||
type SignalSetter<T = any> = (T | (T) -> T) -> ()
|
||||
type ReactiveSetter<T = any> = (T | (T) -> ()) -> ()
|
||||
|
||||
local trackedSignals: { [string]: SignalSetter } = {}
|
||||
local trackedReactives: { [string]: ReactiveSetter } = {}
|
||||
|
|
@ -24,12 +22,12 @@ local trackedReactives: { [string]: ReactiveSetter } = {}
|
|||
|
||||
@param signals A map of setter functions to sync with the server.
|
||||
]]
|
||||
local function addSignals(signals: { [string]: SignalSetter | { [any]: any } })
|
||||
local function addSignals(signals: { [string]: SignalSetter | {} })
|
||||
for key, signal in signals do
|
||||
if type(signal) == "function" then
|
||||
trackedSignals[key] = signal
|
||||
else
|
||||
trackedReactives[key] = getmetatable((signal :: any) :: Reactive).setValue
|
||||
trackedReactives[key] = getmetatable(signal :: ReactiveProxy).set
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ end
|
|||
long as the target array is not sparse. This function ensures that the diff
|
||||
function produces a patch that can be correctly applied.
|
||||
]]
|
||||
local function stringifySparseArray(object: { any }): { [string | number]: any }
|
||||
local function stringifySparseArray(object: {}): { [string | number]: any }
|
||||
local maxn = table.maxn(object)
|
||||
|
||||
if maxn == 0 or maxn == #object then
|
||||
|
|
@ -65,7 +65,7 @@ local function diff(oldState: any, newState: any): any
|
|||
return newState
|
||||
end
|
||||
|
||||
local diffs: { [any]: any } = table.clone(newState)
|
||||
local diffs = table.clone(newState)
|
||||
|
||||
for key, oldValue in oldState do
|
||||
diffs[key] = diff(oldValue, newState[key])
|
||||
|
|
|
|||
|
|
@ -4,20 +4,19 @@ 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
|
||||
|
||||
type ClientKeys = { [string]: SyncStatus }
|
||||
|
||||
type Reactive<T> = typeof(setmetatable({}, {} :: { initialValue: T, track: () -> () }))
|
||||
|
||||
type TrackedState<T = any> = {
|
||||
target: any,
|
||||
currentValue: T,
|
||||
lastSyncedValue: T,
|
||||
dispose: () -> (),
|
||||
isReactive: boolean?,
|
||||
dispose: () -> (),
|
||||
}
|
||||
|
||||
type StateUpdate = {
|
||||
|
|
@ -68,21 +67,21 @@ local function trackSignal<T>(key: string, getter: () -> T)
|
|||
trackedStates[key] = trackedState
|
||||
end
|
||||
|
||||
local function trackReactive<T>(key: string, reactive: Reactive<T>)
|
||||
local function trackReactive<T>(key: string, proxy: ReactiveProxy<T>)
|
||||
if trackedStates[key] then
|
||||
if trackedStates[key].target ~= reactive then
|
||||
if trackedStates[key].target ~= proxy then
|
||||
warn("[Charm] Attempted to sync different states with the same key:", key)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local meta = getmetatable(reactive)
|
||||
local snapshot = utils.deepCopy(meta.initialValue)
|
||||
local meta = getmetatable(proxy)
|
||||
local snapshot = utils.deepCopy(meta.value)
|
||||
local firstRun = true
|
||||
|
||||
local trackedState = {
|
||||
target = reactive,
|
||||
currentValue = meta.initialValue,
|
||||
target = proxy,
|
||||
currentValue = meta.value,
|
||||
lastSyncedValue = snapshot,
|
||||
isReactive = true,
|
||||
}
|
||||
|
|
@ -93,7 +92,7 @@ local function trackReactive<T>(key: string, reactive: Reactive<T>)
|
|||
firstRun = false
|
||||
else
|
||||
local oldSnapshot = snapshot
|
||||
snapshot = utils.deepCopy(meta.initialValue)
|
||||
snapshot = utils.deepCopy(meta.value)
|
||||
table.insert(globalUpdates, {
|
||||
key = key,
|
||||
newValue = snapshot,
|
||||
|
|
@ -258,7 +257,7 @@ end
|
|||
@param client The client receiving state updates.
|
||||
@param signals A map of getter functions to sync with the client.
|
||||
]]
|
||||
local function addSignalsToClient(client: any, signals: { [string]: { [any]: any } | () -> any })
|
||||
local function addSignalsToClient(client: any, signals: { [string]: {} | () -> any })
|
||||
if not clients[client] then
|
||||
clients[client] = {}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,10 +6,6 @@ type NestedTable = {
|
|||
[any]: NestedTable | any,
|
||||
}
|
||||
|
||||
type FlattenedTable = {
|
||||
[any]: any,
|
||||
}
|
||||
|
||||
--[[
|
||||
Flattens a nested table into a single table with slash-separated keys.
|
||||
Useful for recursively collecting atoms returned by modules.
|
||||
|
|
@ -17,15 +13,15 @@ type FlattenedTable = {
|
|||
@param input A nested table.
|
||||
@return A flattened table with slash-separated keys.
|
||||
]]
|
||||
local function flatten(input: NestedTable): FlattenedTable
|
||||
local result: FlattenedTable = {}
|
||||
local function flatten<Flattened>(input: {}): Flattened
|
||||
local result = {}
|
||||
|
||||
local function visit(node: NestedTable, path: string)
|
||||
local function visit(node: {}, path: string)
|
||||
for key, value in node do
|
||||
local location = if path == "" then key else path .. "/" .. key
|
||||
local location = if path == "" then tostring(key) else path .. "/" .. tostring(key)
|
||||
|
||||
if type(value) == "table" then
|
||||
visit(value :: NestedTable, location)
|
||||
visit(value :: {}, location)
|
||||
else
|
||||
result[location] = value
|
||||
end
|
||||
|
|
@ -34,7 +30,7 @@ local function flatten(input: NestedTable): FlattenedTable
|
|||
|
||||
visit(input, "")
|
||||
|
||||
return result
|
||||
return result :: any
|
||||
end
|
||||
|
||||
--[[
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
local function isTableUnsafe(object: { [any]: any })
|
||||
local function isTableUnsafe(object: {})
|
||||
local key = next(object)
|
||||
|
||||
if key == nil then
|
||||
|
|
@ -12,7 +12,7 @@ local function isTableUnsafe(object: { [any]: any })
|
|||
for key in object do
|
||||
if type(key) ~= keyType then
|
||||
return true
|
||||
elseif keyType == "number" and key > maxKey then
|
||||
elseif type(key) == "number" and key > maxKey then
|
||||
maxKey = key
|
||||
end
|
||||
end
|
||||
|
|
@ -33,7 +33,7 @@ end
|
|||
@param value The table value.
|
||||
@param key The key of the value.
|
||||
]]
|
||||
local function validate(value: { [any]: any })
|
||||
local function validate(value: {})
|
||||
if isTableUnsafe(value) then
|
||||
warn(
|
||||
`Cannot sync tables unsupported by remote events! See the table below for more info.\n\n`
|
||||
|
|
|
|||
|
|
@ -1,22 +1,19 @@
|
|||
local Charm = require("../")
|
||||
local suite = require("../../../../test/suite")
|
||||
|
||||
local atom = Charm.atom
|
||||
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 test = suite.test
|
||||
|
||||
--[[
|
||||
Creates a weak reference to a value.
|
||||
]]
|
||||
local function weakRef<T>(value: T): { value: T? }
|
||||
return setmetatable({ value = value }, { __mode = "v" }) :: any
|
||||
end
|
||||
|
||||
--[[
|
||||
Triggers garbage collection by creating a large number of tables.
|
||||
]]
|
||||
local function gc()
|
||||
for _ = 1, 2000 do
|
||||
local _ = table.create(100, 0)
|
||||
|
|
@ -41,14 +38,14 @@ end
|
|||
|
||||
test("should release computed cache", function()
|
||||
local srcRef = weakRef({})
|
||||
local src = atom(srcRef.value)
|
||||
local src, setSrc = signal(srcRef.value)
|
||||
local c = computed(function()
|
||||
return src()
|
||||
end)
|
||||
|
||||
c() -- cache src value
|
||||
src(nil) -- release value
|
||||
c = unlink(c) -- release computed
|
||||
setSrc(nil) -- release value
|
||||
unlink(c) -- release computed
|
||||
|
||||
gc()
|
||||
assert(srcRef.value == nil, "should release computed cache")
|
||||
|
|
@ -56,7 +53,7 @@ end)
|
|||
|
||||
test("should not release effect for atom", function()
|
||||
local effectCalls = 0
|
||||
local src = atom(0)
|
||||
local src, setSrc = signal(0)
|
||||
|
||||
effect(function()
|
||||
effectCalls += 1
|
||||
|
|
@ -65,8 +62,52 @@ test("should not release effect for atom", function()
|
|||
assert(effectCalls == 1, "should call effect once")
|
||||
|
||||
gc()
|
||||
src(src() + 1)
|
||||
setSrc(src() + 1)
|
||||
assert(effectCalls == 2, "should call effect again")
|
||||
end)
|
||||
|
||||
test("should release reactive tables", function()
|
||||
local valueRef = weakRef({})
|
||||
local src = reactive({ value = valueRef.value })
|
||||
|
||||
effect(function()
|
||||
local _ = src.value -- create proxy
|
||||
end)
|
||||
src.value = nil :: any -- release proxy
|
||||
|
||||
gc()
|
||||
assert(valueRef.value == nil, "should release reactive table")
|
||||
assert((src :: ReactiveProxy).__signals.value == nil, "should release reactive signals")
|
||||
end)
|
||||
|
||||
test("should release reactive property deps", function()
|
||||
local src = reactive({ foo = 1 })
|
||||
|
||||
local c = computed(function()
|
||||
return src.foo
|
||||
end)
|
||||
|
||||
c()
|
||||
assert((src :: ReactiveProxy).__signals.foo ~= nil, "should have deps for foo")
|
||||
|
||||
c = nil :: any
|
||||
gc()
|
||||
assert((src :: ReactiveProxy).__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)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ local suite = require("../../../../test/suite")
|
|||
|
||||
local effect = Charm.effect
|
||||
local reactive = Charm.reactive
|
||||
local toRaw = Charm.toRaw
|
||||
local test = suite.test
|
||||
|
||||
test("should track nested properties", function()
|
||||
|
|
@ -121,4 +122,22 @@ test("should overwrite with table passed to setter", function()
|
|||
assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ","))
|
||||
end)
|
||||
|
||||
test("should unwrap raw table", function()
|
||||
local raw = {}
|
||||
local src = reactive(raw)
|
||||
local runs = 0
|
||||
|
||||
effect(function()
|
||||
local _ = toRaw(src)
|
||||
runs += 1
|
||||
end)
|
||||
|
||||
assert(toRaw(src) == raw, "unwrap did not return the original table")
|
||||
assert(toRaw(raw) == raw, "unwrap modified a non-proxy value")
|
||||
assert(runs == 1, runs)
|
||||
|
||||
src.x = 1
|
||||
assert(runs == 2, runs)
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
13
packages/charm/src/index.d.ts
vendored
13
packages/charm/src/index.d.ts
vendored
|
|
@ -302,14 +302,23 @@ export function recursive(): void;
|
|||
* ```
|
||||
*
|
||||
* @param initialValue The value to make deeply reactive.
|
||||
* @return A proxy table that tracks reads to nested properties.
|
||||
* @return A function for updating the original table.
|
||||
* @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]>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
export function getActiveSub(): ReactiveNode | undefined;
|
||||
|
||||
export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined;
|
||||
|
|
|
|||
|
|
@ -6,13 +6,19 @@ export type Link = system.Link
|
|||
|
||||
export type Atom<T = any> = (...((T) -> T) | T) -> T
|
||||
|
||||
type Target = { [any]: any }
|
||||
|
||||
type Proxy = {
|
||||
__target: Target,
|
||||
__signals: { [any]: () -> any },
|
||||
type ReactiveProxyMeta<T = {}> = {
|
||||
value: T,
|
||||
track: () -> (),
|
||||
set: (T | (T) -> ()?) -> (),
|
||||
}
|
||||
|
||||
type ReactiveProxyProps<T = {}> = {
|
||||
__target: T,
|
||||
__signals: { [unknown]: () -> unknown },
|
||||
}
|
||||
|
||||
export type ReactiveProxy<T = {}> = T & typeof(setmetatable({} :: ReactiveProxyProps<T>, {} :: ReactiveProxyMeta<T>))
|
||||
|
||||
type EffectScopeNode = ReactiveNode & {
|
||||
cleanups: { () -> () },
|
||||
}
|
||||
|
|
@ -135,7 +141,7 @@ local function runCleanups(effect: EffectScopeNode)
|
|||
end
|
||||
end
|
||||
|
||||
local function run(e: EffectNode): ()
|
||||
local function run(e: EffectNode)
|
||||
local flags = e.flags
|
||||
if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then
|
||||
runCleanups(e)
|
||||
|
|
@ -157,7 +163,7 @@ local function run(e: EffectNode): ()
|
|||
end
|
||||
end
|
||||
|
||||
local function flush(): ()
|
||||
local function flush()
|
||||
while notifyIndex < queuedLength do
|
||||
local effect = queued[notifyIndex] :: EffectNode
|
||||
queued[notifyIndex] = nil
|
||||
|
|
@ -320,6 +326,50 @@ local function effectOper(this: EffectScopeNode): () -> ()
|
|||
end
|
||||
end
|
||||
|
||||
local function update(node: ReactiveNode): boolean
|
||||
if node.depsTail then
|
||||
return updateComputed(node :: ComputedNode)
|
||||
else
|
||||
return updateSignal(node :: SignalNode)
|
||||
end
|
||||
end
|
||||
|
||||
local function notify(effect: ReactiveNode)
|
||||
local insertIndex = queuedLength
|
||||
local firstInsertedIndex = insertIndex
|
||||
|
||||
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
|
||||
|
||||
insertIndex -= 1
|
||||
while firstInsertedIndex < insertIndex do
|
||||
local left = queued[firstInsertedIndex]
|
||||
queued[firstInsertedIndex] = queued[insertIndex]
|
||||
queued[insertIndex] = left
|
||||
firstInsertedIndex += 1
|
||||
insertIndex -= 1
|
||||
end
|
||||
end
|
||||
|
||||
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.
|
||||
|
|
@ -590,6 +640,7 @@ end
|
|||
local function listen<T>(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> ()
|
||||
local tracked = toSignal(getter)
|
||||
local current: T
|
||||
|
||||
return effect(function()
|
||||
local previous = current
|
||||
current = tracked()
|
||||
|
|
@ -614,6 +665,7 @@ local function subscribe<T>(getter: () -> T, fn: (state: T, lastState: T) -> ())
|
|||
local tracked = toSignal(getter)
|
||||
local current: T
|
||||
local firstRun = true
|
||||
|
||||
return effect(function()
|
||||
local previous = current
|
||||
current = tracked()
|
||||
|
|
@ -673,6 +725,7 @@ local function observe<K, V>(getter: () -> { [K]: V }, fn: (item: V, key: K) ->
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
for key, value in data do
|
||||
if innerScopes[key] == nil then
|
||||
local disposeScope = effectScope(function()
|
||||
|
|
@ -696,6 +749,7 @@ local function observe<K, V>(getter: () -> { [K]: V }, fn: (item: V, key: K) ->
|
|||
-- interfere with the current update cycle.
|
||||
batch(updateScopes, getter())
|
||||
end)
|
||||
|
||||
return function()
|
||||
if disposed then
|
||||
return
|
||||
|
|
@ -781,16 +835,16 @@ end
|
|||
@example
|
||||
```luau
|
||||
local users, setUsers = reactive({
|
||||
user = { name = "John", surname = "Doe" },
|
||||
{ name = "John", surname = "Doe" },
|
||||
})
|
||||
|
||||
effect(function()
|
||||
print(users.user and `{users.user.name} {users.user.surname}`)
|
||||
print(`{users[1].name} {users[1].surname}`)
|
||||
end)
|
||||
|
||||
setUsers(function(state)
|
||||
state.user.name = "Jane"
|
||||
state.user.surname = "Smith"
|
||||
state[1].name = "Jane"
|
||||
state[1].surname = "Smith"
|
||||
end)
|
||||
```
|
||||
|
||||
|
|
@ -801,53 +855,32 @@ end
|
|||
]]
|
||||
local function reactive<T>(initialValue: T & {}): (T, (update: (T) -> () | T?) -> ())
|
||||
local track = signal(nil)
|
||||
local proxyMeta = { value = initialValue, track = track }
|
||||
local proxyMap = setmetatable({} :: { [{}]: any }, { __mode = "v" })
|
||||
|
||||
local function setValue(update: ((T) -> ()) | T?)
|
||||
if type(update) == "function" then
|
||||
update(initialValue)
|
||||
elseif type(update) == "table" then
|
||||
table.clear(initialValue)
|
||||
for key, value in update do
|
||||
(initialValue :: Target)[key] = value
|
||||
end
|
||||
local function trackProperty(cache: { [any]: () -> any }, target: {}, key: any)
|
||||
if cache[key] then
|
||||
return cache[key]()
|
||||
end
|
||||
trigger(track)
|
||||
cache[key] = computed(function()
|
||||
track()
|
||||
return target[key]
|
||||
end)
|
||||
return cache[key]()
|
||||
end
|
||||
|
||||
local proxyMeta = {
|
||||
initialValue = initialValue,
|
||||
track = track,
|
||||
setValue = setValue,
|
||||
}
|
||||
|
||||
local proxyMap = setmetatable({} :: { [Target]: any }, { __mode = "k" })
|
||||
|
||||
local function trackProperty(cache: { [any]: () -> any }, target: Target, key: any)
|
||||
if not cache[key] then
|
||||
cache[key] = untracked(computed, function()
|
||||
track()
|
||||
return target[key]
|
||||
end)
|
||||
end
|
||||
cache[key]()
|
||||
end
|
||||
|
||||
local function createProxy(target: Target): Proxy
|
||||
if not proxyMap[target] then
|
||||
proxyMap[target] = setmetatable({
|
||||
__target = target,
|
||||
__signals = setmetatable({}, { __mode = "v" }),
|
||||
}, proxyMeta)
|
||||
local function createProxy<T>(target: T & {}): ReactiveProxy<T>
|
||||
if proxyMap[target] then
|
||||
return proxyMap[target]
|
||||
end
|
||||
proxyMap[target] = setmetatable({
|
||||
__target = target,
|
||||
__signals = setmetatable({}, { __mode = "v" }),
|
||||
}, proxyMeta)
|
||||
return proxyMap[target]
|
||||
end
|
||||
|
||||
local function nextProxy(target: Target, index: any)
|
||||
local nextKey, nextValue = next(target, index)
|
||||
return nextKey, if type(nextValue) == "table" then createProxy(nextValue) else nextValue
|
||||
end
|
||||
|
||||
function proxyMeta.__index(self: Proxy, key: any): any
|
||||
function proxyMeta.__index(self: ReactiveProxy, key: any): any
|
||||
if activeSub then
|
||||
trackProperty(self.__signals, self.__target, key)
|
||||
end
|
||||
|
|
@ -855,7 +888,7 @@ local function reactive<T>(initialValue: T & {}): (T, (update: (T) -> () | T?) -
|
|||
return if type(value) == "table" then createProxy(value) else value
|
||||
end
|
||||
|
||||
function proxyMeta.__newindex(self: Proxy, key: any, value: any)
|
||||
function proxyMeta.__newindex(self: ReactiveProxy, key: any, value: any)
|
||||
if type(value) == "table" and getmetatable(value) == proxyMeta then
|
||||
value = value.__target
|
||||
end
|
||||
|
|
@ -865,61 +898,61 @@ local function reactive<T>(initialValue: T & {}): (T, (update: (T) -> () | T?) -
|
|||
end
|
||||
end
|
||||
|
||||
function proxyMeta.__iter(self: Proxy)
|
||||
function proxyMeta.__iter(self: ReactiveProxy)
|
||||
track()
|
||||
return nextProxy, self.__target
|
||||
local function iterator(target: {}, index: any)
|
||||
local key, value = next(target, index)
|
||||
return key, if type(value) == "table" then createProxy(value) else value
|
||||
end
|
||||
return iterator, self.__target
|
||||
end
|
||||
|
||||
function proxyMeta.__len(self: Proxy)
|
||||
function proxyMeta.__len(self: ReactiveProxy)
|
||||
track()
|
||||
return #self.__target
|
||||
end
|
||||
|
||||
return createProxy(initialValue) :: any, setValue
|
||||
function proxyMeta.set(update: ((T) -> ()) | T?)
|
||||
if type(update) == "function" then
|
||||
update(initialValue)
|
||||
elseif type(update) == "table" then
|
||||
table.clear(initialValue)
|
||||
for key, value in update :: {} do
|
||||
(initialValue :: {})[key] = value
|
||||
end
|
||||
end
|
||||
trigger(track)
|
||||
end
|
||||
|
||||
return createProxy(initialValue), proxyMeta.set
|
||||
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 unwrapped target value.
|
||||
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
|
||||
]]
|
||||
local function toRaw<T>(value: T): T
|
||||
if type(value) == "table" and getmetatable(value) ~= nil then
|
||||
local proxy: ReactiveProxy = value
|
||||
local meta = getmetatable(proxy)
|
||||
if meta.value == proxy.__target then
|
||||
-- Track the root proxy since nested proxies are already tracked
|
||||
meta.track()
|
||||
end
|
||||
return proxy.__target :: any
|
||||
else
|
||||
return value
|
||||
end
|
||||
end
|
||||
|
||||
local reactiveSystem = system.createReactiveSystem({
|
||||
update = function(node)
|
||||
if node.depsTail then
|
||||
return updateComputed(node :: ComputedNode)
|
||||
else
|
||||
return updateSignal(node :: SignalNode)
|
||||
end
|
||||
end,
|
||||
notify = function(effect)
|
||||
local insertIndex = queuedLength
|
||||
local firstInsertedIndex = insertIndex
|
||||
|
||||
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
|
||||
|
||||
insertIndex -= 1
|
||||
while firstInsertedIndex < insertIndex do
|
||||
local left = queued[firstInsertedIndex]
|
||||
queued[firstInsertedIndex] = queued[insertIndex]
|
||||
queued[insertIndex] = left
|
||||
firstInsertedIndex += 1
|
||||
insertIndex -= 1
|
||||
end
|
||||
end,
|
||||
unwatched = function(node)
|
||||
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,
|
||||
update = update,
|
||||
notify = notify,
|
||||
unwatched = unwatched,
|
||||
})
|
||||
|
||||
link = reactiveSystem.link
|
||||
|
|
@ -954,4 +987,5 @@ return {
|
|||
mapper: (item: VI, key: KI) -> (VO, KO)
|
||||
) -> () -> { [KO]: VO }) & (<K, VI, VO>(getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }),
|
||||
reactive = reactive,
|
||||
toRaw = toRaw,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ local function createReactiveSystem(props: {
|
|||
return false
|
||||
end
|
||||
|
||||
local function link(dep: ReactiveNode, sub: ReactiveNode, version: number): ()
|
||||
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
|
||||
|
|
@ -166,7 +166,7 @@ local function createReactiveSystem(props: {
|
|||
return nextDep
|
||||
end
|
||||
|
||||
local function propagate(link: Link): ()
|
||||
local function propagate(link: Link)
|
||||
repeat
|
||||
local sub = link.sub
|
||||
local flags = sub.flags
|
||||
|
|
@ -200,7 +200,7 @@ local function createReactiveSystem(props: {
|
|||
until not link
|
||||
end
|
||||
|
||||
local function shallowPropagate(link: Link): ()
|
||||
local function shallowPropagate(link: Link)
|
||||
repeat
|
||||
local sub = link.sub
|
||||
local flags = sub.flags
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue