From 9f66803f04552d6806cd7ca2e006a66812eec2cc Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 19 Nov 2025 21:38:56 -0800 Subject: [PATCH] feat: add `toRaw()` --- README.md | 104 ++++---- packages/charm-sync/Charm.luau | 1 + .../charm-sync/src/__tests__/patch.spec.luau | 2 +- packages/charm-sync/src/client.luau | 12 +- packages/charm-sync/src/patch.luau | 4 +- packages/charm-sync/src/server.luau | 21 +- packages/charm-sync/src/utils.luau | 16 +- packages/charm-sync/src/validate.luau | 6 +- packages/charm/src/__tests__/gc.spec.luau | 65 ++++- .../charm/src/__tests__/reactive.spec.luau | 19 ++ packages/charm/src/index.d.ts | 13 +- packages/charm/src/init.luau | 226 ++++++++++-------- packages/charm/src/system.luau | 6 +- 13 files changed, 297 insertions(+), 198 deletions(-) diff --git a/README.md b/README.md index 24a753c..d2301ef 100644 --- a/README.md +++ b/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) +
Table of Contents @@ -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 --- diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau index 9a55fe0..a589bde 100644 --- a/packages/charm-sync/Charm.luau +++ b/packages/charm-sync/Charm.luau @@ -5,5 +5,6 @@ end local Charm = require(script.Parent.Parent.charm.src) export type Atom = Charm.Atom +export type ReactiveProxy = Charm.ReactiveProxy return Charm diff --git a/packages/charm-sync/src/__tests__/patch.spec.luau b/packages/charm-sync/src/__tests__/patch.spec.luau index 65c0e45..cad535f 100644 --- a/packages/charm-sync/src/__tests__/patch.spec.luau +++ b/packages/charm-sync/src/__tests__/patch.spec.luau @@ -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 diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 43103a7..1435619 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -2,13 +2,11 @@ local Charm = require("../Charm") local patch = require("./patch") local types = require("./types") +type ReactiveProxy = Charm.ReactiveProxy type SyncPayload = types.SyncPayload -type SignalSetter = (update: ((T) -> T) | T) -> () - -type ReactiveSetter = (update: ((T) -> ()) | T) -> () - -type Reactive = typeof(setmetatable({}, {} :: { setValue: ReactiveSetter })) +type SignalSetter = (T | (T) -> T) -> () +type ReactiveSetter = (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 diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 15bfd2c..3892e9a 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -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]) diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index a43e3b9..d9b1694 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -4,20 +4,19 @@ local patch = require("./patch") local types = require("./types") local utils = require("./utils") +type ReactiveProxy = Charm.ReactiveProxy type SyncPayload = types.SyncPayload type SyncStatus = number type ClientKeys = { [string]: SyncStatus } -type Reactive = typeof(setmetatable({}, {} :: { initialValue: T, track: () -> () })) - type TrackedState = { target: any, currentValue: T, lastSyncedValue: T, - dispose: () -> (), isReactive: boolean?, + dispose: () -> (), } type StateUpdate = { @@ -68,21 +67,21 @@ local function trackSignal(key: string, getter: () -> T) trackedStates[key] = trackedState end -local function trackReactive(key: string, reactive: Reactive) +local function trackReactive(key: string, proxy: ReactiveProxy) 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(key: string, reactive: Reactive) 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 diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau index fbf9260..198f482 100644 --- a/packages/charm-sync/src/utils.luau +++ b/packages/charm-sync/src/utils.luau @@ -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(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 --[[ diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau index c108c73..ac21161 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -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` diff --git a/packages/charm/src/__tests__/gc.spec.luau b/packages/charm/src/__tests__/gc.spec.luau index 34c8d4e..3ab4dfd 100644 --- a/packages/charm/src/__tests__/gc.spec.luau +++ b/packages/charm/src/__tests__/gc.spec.luau @@ -1,22 +1,19 @@ local Charm = require("../") local suite = require("../../../../test/suite") -local atom = Charm.atom +type ReactiveProxy = Charm.ReactiveProxy + +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(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 {} diff --git a/packages/charm/src/__tests__/reactive.spec.luau b/packages/charm/src/__tests__/reactive.spec.luau index 70237b9..48b4b58 100644 --- a/packages/charm/src/__tests__/reactive.spec.luau +++ b/packages/charm/src/__tests__/reactive.spec.luau @@ -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 {} diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 973b458..247fe91 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -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( 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(value: T): T; + export function getActiveSub(): ReactiveNode | undefined; export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 4334a54..7b6f4ae 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -6,13 +6,19 @@ export type Link = system.Link export type Atom = (...((T) -> T) | T) -> T -type Target = { [any]: any } - -type Proxy = { - __target: Target, - __signals: { [any]: () -> any }, +type ReactiveProxyMeta = { + value: T, + track: () -> (), + set: (T | (T) -> ()?) -> (), } +type ReactiveProxyProps = { + __target: T, + __signals: { [unknown]: () -> unknown }, +} + +export type ReactiveProxy = T & typeof(setmetatable({} :: ReactiveProxyProps, {} :: ReactiveProxyMeta)) + 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(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(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(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(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(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(target: T & {}): ReactiveProxy + 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(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(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(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 }) & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }), reactive = reactive, + toRaw = toRaw, } diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau index 61834fb..565419e 100644 --- a/packages/charm/src/system.luau +++ b/packages/charm/src/system.luau @@ -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