diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 912f2ea..5cd5741 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,13 +20,8 @@ jobs: - name: Check code quality run: lute run check - - name: Run ESLint and Prettier - run: | - pnpm eslint packages - pnpm prettier --check packages - - name: Run tests - run: lute run test + run: lute run tests - name: Build models run: lute run build diff --git a/.luaurc b/.luaurc index c48b0f2..c56c621 100644 --- a/.luaurc +++ b/.luaurc @@ -1,7 +1,10 @@ { - "languageMode": "strict", - "aliases": { - "lute": "~/.lute/typedefs/1.0.0/lute", - "std": "~/.lute/typedefs/1.0.0/std" - } -} + "languageMode": "strict", + "aliases": { + "lute": "~/.lute/typedefs/0.1.0/lute", + "std": "~/.lute/typedefs/0.1.0/std" + }, + "lint": { + "LocalShadow": false + } +} \ No newline at end of file diff --git a/.lute/build.luau b/.lute/build.luau index b708003..f1b5876 100644 --- a/.lute/build.luau +++ b/.lute/build.luau @@ -1,6 +1,6 @@ local fs = require("@std/fs") local process = require("@lute/process") -fs.createDirectory("build") +fs.createdirectory("build", { makeparents = true }) process.run({ "rojo", "build", "--output=build/charm.rbxm", "build.project.json" }, { stdio = "inherit" }) diff --git a/.lute/check.luau b/.lute/check.luau index b5ad31b..4117286 100644 --- a/.lute/check.luau +++ b/.lute/check.luau @@ -1,10 +1,10 @@ local fs = require("@std/fs") -local net = require("@std/net") +local net = require("@lute/net") local process = require("@lute/process") process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" }) -fs.writeStringToFile( +fs.writestringtofile( "roblox.d.luau", net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body ) @@ -16,7 +16,6 @@ local analyze = process.run({ "--ignore=**/node_modules/**", "--base-luaurc=.luaurc", "--definitions=roblox.d.luau", - "--flag:LuauSolverV2=true", "packages", "tests", "benches", @@ -24,10 +23,12 @@ local analyze = process.run({ fs.remove("roblox.d.luau") +local selene = process.run({ "selene", "packages", "tests", "benches" }, { stdio = "inherit" }) + local stylua = process.run({ "stylua", "--check", "packages", "tests", "benches" }, { stdio = "inherit" }) local eslint = process.run({ "pnpm", "eslint", "packages" }, { stdio = "inherit" }) -if not (analyze.ok and stylua.ok and eslint.ok) then +if not (analyze.ok and selene.ok and stylua.ok and eslint.ok) then process.exit(1) end diff --git a/.lute/test.luau b/.lute/test.luau deleted file mode 100644 index 4abbbb9..0000000 --- a/.lute/test.luau +++ /dev/null @@ -1 +0,0 @@ -(require)("../tests/run.server") diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b644580 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +public-hoist-pattern[]=*@rbxts* diff --git a/README.md b/README.md index 07d9641..71d5318 100644 --- a/README.md +++ b/README.md @@ -18,20 +18,20 @@ -Charm is a reactive state management library designed for storing data in discrete containers called _signals_. Connect behavior to data with signals, ensuring systems stay up-to-date with their underlying data while reactivity eliminates the need for manual updates. +Charm is a state management library based on [fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf). Connect behavior to data with reactive signals, ensuring games stay up-to-date with their underlying data while eliminating the need for manual updates. **Build game state from simple building blocks:** -- Store data in [signals](#signalinitialvalue-equals): reactive state containers that hold a value -- React to state changes with [effects](#effectcallback): re-run code when signals update -- Derive new values from data with [computed signals](#computedgetter): memoize functions that access signals +- Store state in [signals](#signalinitialvalue-equals): state containers that hold a value +- React to state changes with [effects](#effectcallback): re-run code when a dependency updates +- Derive values from state with [computed signals](#computedgetter): memoized functions with dependency tracking -**Want to learn more about reactivity?** +**Want to learn more about signals?** - https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf - https://preactjs.com/blog/introducing-signals - https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity -- https://github.com/roblox/signals +- https://angular.dev/guide/signals [Migrating from an older version of Charm?](#migration) @@ -51,7 +51,7 @@ Charm is a reactive state management library designed for storing data in discre - [`subscribe(getter, callback)`](#subscribegetter-callback) - [`untracked(callback)`](#untrackedcallback) - [`batch(callback)`](#batchcallback) - - [`mapped(getter, transform)`](#mappedgetter-transform) + - [`mapped(getter, mapper)`](#mappedgetter-mapper) - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) - [`atom(initialValue, equals?)`](#atominitialvalue-equals) - [`trigger(callback)`](#triggercallback) @@ -472,11 +472,11 @@ end) --- -### `mapped(getter, transform)` +### `mapped(getter, mapper)` -The `mapped` function iterates over every key in a table and uses the transform function to assign them to a new key and value. The result is returned as a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, `transform` is called for that key and its current value. +The `mapped` function iterates over every key in a table and uses the mapper to assign them to a new key and value. The result is returned as a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, the mapper is called for that key and its current value. -The first value returned by the transform function is used as the new value: +The first value returned by the mapper is used as the new value: ```luau local getList, setList = signal({ "a", "b", "c" }) @@ -488,7 +488,7 @@ end) print(getUppercase()) -- { "A", "B", "C" } ``` -If the transform function returns two values, the second value is used as the new key: +If the mapper returns two values, the second value is used as the new key: ```luau local getList, setList = signal({ "a", "b", "c" }) @@ -602,7 +602,7 @@ Charm exposes the following global flags to customize behavior: | frozen | `true`\* | Enforces data immutability by deep-freezing tables passed to signals, excluding objects with metatables. | | trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. | -The `strict` and `frozen` flags are automatically enabled in Roblox Studio. +The `strict` and `frozen` flags are automatically enabled in Roblox Studio and other development environments where the Luau optimization level is `O1` or lower. --- @@ -623,99 +623,72 @@ CharmSync = "littensy/charm-sync@VERSION" ### Quick Start -Start by specifying the signals that the server should sync to clients. For this example, we'll replicate a round status and timer signal, and each player's inventory: +Start by specifying the signals that the server should sync to clients. For this example, we'll use the first and last name signals: ```luau --- store.luau - -local getStatus, setStatus = signal("intermission") -local timerAtom = atom(20) -local getInventories, setInventories = signal({} :: { [Username]: Inventory }) +-- nameStore +local getName, setName = signal("John") +local getSurname, setSurname = signal("Doe") +local ageAtom = atom(20) return { - getStatus = getStatus, - setStatus = setStatus, - timerAtom = timerAtom, - getInventories = getInventories, - setInventories = setInventories, + getName = getName, + setName = setName, + getSurname = getSurname, + setSurname = setSurname, + ageAtom = ageAtom, } ``` -#### Server setup +When a player joins on the server, call `server.addSignalsToClient` with the keyed signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates. -When a player notifies the server that they're ready to start syncing, call `server.addSignalsToClient` with the signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates. - -Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to: +Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to. ```luau --- sync.server.luau - -playerReadyEvent.OnServerEvent:Connect(function(player) - -- Sync signal getters, computed signals, atoms, or reactive proxies +local function onPlayerAdded(player: Player) + -- Add signal getters, computed signals, atoms, or reactive objects server.addSignalsToClient(player, { - status = store.getStatus, - timer = store.timerAtom, - -- Signals unique to a player should also have a key unique to - -- that player, to avoid overwriting other synced signals - [`inventory-{player.UserId}`] = computed(function() - return store.getInventories()[player.Name] - end), + name = nameStore.getName, + surname = nameStore.getSurname, + age = nameStore.ageAtom, }) -end) +end + +for _, player in Players:GetPlayers() do + onPlayerAdded(player) +end + +Players.PlayerAdded:Connect(onPlayerAdded) Players.PlayerRemoving:Connect(function(player) server.removeClient(player) end) server.connect(function(player, updates) - -- Customize how you replicate state updates to clients + -- Customize how you send state updates to clients syncEvent:FireClient(player, updates) end) - --- Changes now get replicated to clients -store.setStatus("started") -store.timerAtom(30) ``` -> [!WARNING] -> On the server, make sure each key corresponds to the same signal across all players. In other words, if you're syncing a signal that is unique to a player, ensure that the key is also unique to that player. -> -> If two players subscribe to the same key, but were assigned different signals for that key, Charm will output a warning. - -#### Client setup +> [!NOTE] +> On the server, make sure each key corresponds to the same signal across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning. To sync the client with the server's state, call `client.addSignals` with a table of writable signals (setter functions or atoms) whose keys match their server counterparts. After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates. ```luau --- sync.client.luau - --- The server is replicating a signal containing the client's --- inventory, so this signal will receive those updates -local getInventory, setInventory = signal({} :: Inventory) - -- Add signal setters, atoms, or reactive proxies client.addSignals({ - status = store.setStatus, - timer = store.timerAtom, - -- Signals unique to a player should also have a key unique to - -- that player, to avoid name collisions - [`inventory-{Players.LocalPlayer.UserId}`] = setInventory, + name = nameStore.setName, + surname = nameStore.setSurname, + age = nameStore.ageAtom, }) -- Update client signals when receiving updates from the server syncEvent.OnClientEvent:Connect(function(updates) client.patch(updates) end) - --- Notify the server that the client is ready to receive updates -playerReadyEvent:FireServer() - --- The signals will now update to match the server's state -subscribe(getInventory, function(inventory) - print("My inventory is", table.concat(inventory)) -end) ``` --- @@ -728,49 +701,37 @@ A configuration table that customizes the behavior of Charm Sync on the server. | --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | interval | `0` | The frequency at which the server will send patches to the client, in seconds. A value of `0` sends updates on the next frame. Set to a negative value to disable the interval. | | preserveHistory | `false` | Whether to preserve a full history of state changes since the last sync, at the cost of performance. This is useful if you need to replicate each individual change that occurs between sync events. | -| fixArrays | `true` | When `true`, Charm will attempt to work around remote event limitations that cause data loss in array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). | -| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, synced values containing unsafe sparse arrays or mixed tables will emit a warning. See the [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations). | +| fixArrays | `true` | When `true`, Charm will attempt to work around Roblox remote event limitations regarding array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). | +| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, synced values containing unsafe sparse arrays or mixed tables will throw an error. See the [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations). | --- ### `server.addSignalsToClient(client, getters)` -The `addSignalsToClient` function replicates changes in the given signals to a specific client. When an update occurs, the client will receive a delta-compressed state patch containing only the values that changed. +The `addSignalsToClient` function subscribes a client to updates in the given signals. When an update occurs, the client will receive a state patch of only the values that changed. -You can pass signal getter functions, computed signals, atoms, and [reactive proxies](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals. +You can pass signal getter functions, computed signals, atoms, and [reactive objects](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals. ```luau -playerReadyEvent.OnServerEvent:Connect(function(player) +Players.PlayerAdded:Connect(function(player) server.addSignalsToClient(player, { - status = store.getStatus, - timer = store.timerAtom, + name = nameStore.getName, + surname = nameStore.getSurname, + age = nameStore.ageAtom, }) end) ``` -You're also allowed to create new signals to sync to specific players, as long as the key is unique to that signal: +You're also allowed to create new signals to sync to specific players, as long as the key is unique to that player: ```luau -playerReadyEvent.OnServerEvent:Connect(function(player) - server.addSignalsToClient(player, { - -- 🟢 Good: Player-specific signals are synced with a unique key - [`playerData-{player.UserId}`] = computed(function() - return clientStore.getPlayerData(player.UserId) - end), - - -- 🔴 Bad: Syncing different signals with the same keys does not work - playerData = computed(function() - return clientStore.getPlayerData(player.UserId) - end), - }) -end) +server.addSignalsToClient(player, { + [`data-{player.UserId}`] = computed(function() + return getPlayerData(player.UserId) + end), +}) ``` -> [!NOTE] -> On the server, make sure each key corresponds to the same signal across all players. In other words, if you're syncing a signal that is unique to a player, ensure that the key is also unique to that player. -> -> If two players subscribe to the same key, but were assigned different signals for that key, Charm will output a warning. - --- ### `server.removeSignalsFromClient(client, ...keys)` @@ -779,11 +740,11 @@ The `removeSignalsFromClient` function unsubscribes the client from a list of ke ```luau server.addSignalsToClient(player, { - status = store.getStatus, - timer = store.timerAtom, + name = nameStore.getName, + surname = nameStore.getSurname, }) -server.removeSignalsFromClient(player, "status") +server.removeSignalsFromClient(player, "surname") ``` --- @@ -793,7 +754,7 @@ server.removeSignalsFromClient(player, "status") The `removeClient` function unsubscribes a client from receiving all state updates from the server. You should call this function when a player leaves the game. ```luau -playerReadyEvent.OnServerEvent:Connect(function(player) +Players.PlayerAdded:Connect(function(player) server.addSignalsToClient(player, signals) end) @@ -844,11 +805,9 @@ You can pass either writable signals, atoms, or [reactive objects](#reactiveinit ```luau client.addSignals({ - status = store.setStatus, - timer = store.timerAtom, - -- Signals unique to a player should also have a key unique to - -- that player, to avoid name collisions - [`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory, + name = nameStore.setName, + surname = nameStore.setSurname, + age = nameStore.ageAtom, }) ``` @@ -860,11 +819,11 @@ Unsubscribes from each signal with the corresponding keys. The signals will reta ```luau client.addSignals({ - status = store.setStatus, - timer = store.timerAtom, + name = nameStore.setName, + surname = nameStore.setSurname, }) -client.removeSignals("status") +client.removeSignals("name") ``` --- @@ -877,9 +836,8 @@ You should call `patch` when receiving updates from the server from a remote eve ```luau client.addSignals({ - status = store.setStatus, - timer = store.timerAtom, - [`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory, + name = nameStore.setName, + surname = nameStore.setSurname, }) syncEvent.OnClientEvent:Connect(function(updates) @@ -895,8 +853,8 @@ If you have a lot of signals to sync between the server and clients, it might be ```luau local sharedState = { - status = signalToAtom(store.getStatus, store.setStatus), - inventories = signalToAtom(store.getInventories, store.setInventories), + name = signalToAtom(nameStore.getName, nameStore.setName), + surname = signalToAtom(nameStore.getSurname, nameStore.setSurname), } client.addSignals(sharedState) @@ -909,7 +867,7 @@ server.addSignalsToClient(client, sharedState) Charm Sync will only send clients the differences between the current state and the last-synced state through a process called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`. -But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. Charm addresses this by representing deleted values with a special `None` value denoted by `{ __none = "__none" }`. +But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. We chose to address this by representing deleted values with a special `None` symbol denoted by `{ __none = "__none" }`. This means nilable values may be replaced with `None` in patches, and code working with update payloads (usually for remote argument serialization) should account for nilable values possibly being sent as `None` in the payload. @@ -925,9 +883,6 @@ You can opt-in to deep reactivity with the Deep Charm library: ### Installation -> [!WARNING] -> Deep Charm is an experimental library that is not fully documented, and the API is subject to breaking changes. - ```sh npm install @rbxts/deep-charm yarn add @rbxts/deep-charm @@ -983,22 +938,6 @@ local proxy = reactive(raw) print(toRaw(proxy) == raw) -- true ``` -Calling `toRaw()` on a proxy also subscribes to its changes. This allows you to use proxies with `observe()` and other Charm APIs that accept a getter function: - -```luau -local proxy, updateProxy = reactive({}) - -observe(function() - return toRaw(proxy) -end, function(value, key) - print(`Value {value} added at key {key}`) -end) - -updateProxy(function(state) - table.insert(state, "foo") -end) -- Value foo added at key 1 -``` - --- ### `isReactive(value)` diff --git a/benches/bench.luau b/benches/bench.luau index ffb839d..9c24c6e 100644 --- a/benches/bench.luau +++ b/benches/bench.luau @@ -20,8 +20,8 @@ local function run(name: string, test: () -> ()) table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`) - for name, duration in times do - table.insert(results, `{name}: \t{string.format("%.2f", duration / iter * 1000 * 1000)} us/iter`) + for name, elapsed in times do + table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`) end print(table.concat(results, " | ")) diff --git a/benches/mapped.luau b/benches/mapped.luau index 695b7f5..27ca6bd 100644 --- a/benches/mapped.luau +++ b/benches/mapped.luau @@ -3,56 +3,55 @@ local bench = require("./bench") local computed = Charm.computed local mapped = Charm.mapped +local setActiveSub = Charm.setActiveSub local signal = Charm.signal -type Key = number -type Value = number +type Entry = { number } local count = 512 -local initialState: { [Key]: Value } = table.create(count, 0) +local initialState: { Entry } = table.create(count) -local function expensiveOperation() - for _ = 1, 100 do - continue - end +for i = 1, count do + initialState[i] = table.create(64, i) end -local function simpleMapped( - source: () -> { [Key]: Value }, - mapper: (Value, Key) -> (Value, Key?) -): () -> { [Key]: Value } +local function mapper(value: Entry, key: number): (Entry, number) + return table.clone(value), key + 1 +end + +local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) -> (Entry, number)): () -> { Entry } return computed(function() local state = source() local nextState = {} - for keyIn, valueIn in state do - local valueOut, keyOut = mapper(valueIn, keyIn) - nextState[if keyOut == nil then keyIn else keyOut] = valueOut + for index, value in state do + local currentSub = setActiveSub(nil) + local success, valueOut, keyOut = pcall(mapper, value, index) + setActiveSub(currentSub) + + if success then + nextState[keyOut] = valueOut + end end - return nextState + return state end) end -local function updateRandomly(updateChance: number): ({ [Key]: Value }) -> { [Key]: Value } +local function updatePercentage(percent: number): ({ Entry }) -> { Entry } return function(state) local nextState = table.clone(state) - for key in state do - if math.random() < updateChance then - nextState[key] += 1 + for index, value in state do + if math.random() < percent then + nextState[index] = table.clone(value) end end return nextState end end -for _, updateChance in { 0.1, 0.5, 1 } do - local function mapper(value: Value, key: Key): (Value, Key?) - expensiveOperation() - return value + 1, key + 1 - end - - bench.run(`mapped {updateChance}`, function() +for _, percent in { 0.1, 0.5, 1 } do + bench.run(`mapped {percent}`, function() local src, setSrc = signal(initialState) local out = mapped(src, mapper) @@ -60,14 +59,14 @@ for _, updateChance in { 0.1, 0.5, 1 } do out() bench.profileend() - setSrc(updateRandomly(updateChance)) + setSrc(updatePercentage(percent)) bench.profilebegin("update") out() bench.profileend() end) - bench.run(`control {updateChance}`, function() + bench.run(`control {percent}`, function() local src, setSrc = signal(initialState) local out = simpleMapped(src, mapper) @@ -75,7 +74,7 @@ for _, updateChance in { 0.1, 0.5, 1 } do out() bench.profileend() - setSrc(updateRandomly(updateChance)) + setSrc(updatePercentage(percent)) bench.profilebegin("update") out() diff --git a/benches/multi.luau b/benches/multi.luau deleted file mode 100644 index 6c8235c..0000000 --- a/benches/multi.luau +++ /dev/null @@ -1,188 +0,0 @@ -local Charm = require("../packages/charm/src") -local bench = require("./bench") - -type Atom = Charm.Atom - -local signal = Charm.atom -local computed = Charm.computed -local effect = Charm.effect - -local function busy(): number - local a = 0 - for i = 1, 100 do - a += 1 - end - return a -end - -do - local head = signal(0) - local computed1 = computed(function() - return head() - end) - local computed2 = computed(function() - computed1() - return 0 - end) - local computed3 = computed(function() - busy() -- heavy computation - return computed2() + 1 - end) - local computed4 = computed(function() - return computed3() + 2 - end) - local computed5 = computed(function() - return computed4() + 3 - end) - effect(function() - computed5() - busy() -- heavy side effect - end) - - bench.run("avoidablePropagation", function() - head(1) - assert(computed5() == 6) - for i = 0, 999 do - head(i) - end - assert(computed5() == 6) - end) -end - -do - local head = signal(0) - local double = computed(function() - return head() * 2 - end) - local inverse = computed(function() - return -head() - end) - local current = computed(function() - local result = 0 - for i = 0, 19 do - result += if head() % 2 == 1 then double() else inverse() - end - return result - end) - - local callCounter = 0 - effect(function() - current() - callCounter += 1 - end) - - bench.run("unstable", function() - head(1) - assert(current() == 40) - callCounter = 0 - for i = 0, 99 do - head(i) - end - assert(callCounter == 100) - end) -end - -do - local head = signal(0) - local double = computed(function() - return head() * 2 - end) - local inverse = computed(function() - return -head() - end) - local current = computed(function() - local result = 0 - for i = 0, 19 do - result += if head() % 2 == 1 and i % 2 == 1 then double() else inverse() - end - return result - end) - - local callCounter = 0 - effect(function() - current() - callCounter += 1 - end) - - bench.run("unstable 50%", function() - head(1) - assert(current() == 10) - callCounter = 0 - for i = 0, 99 do - head(i) - end - assert(callCounter == 100) - end) -end - -do - local head = signal(0) - local last: () -> number = head - local callCounter = 0 - - for i = 0, 49 do - local current = computed(function() - return head() + i - end) - local current2 = computed(function() - return current() + 1 - end) - effect(function() - current2() - callCounter += 1 - end) - last = current2 - end - - bench.run("broadPropagation", function() - head(1) - callCounter = 0 - for i = 0, 49 do - head(i) - assert(last() == i + 50) - end - assert(callCounter == 50 * 50) - end) -end - -do - local heads = {} - for index = 0, 99 do - heads[index] = signal(0) - end - - local mux = computed(function() - local result = {} - for index = 0, 99 do - result[index] = heads[index]() - end - return result - end) - - local splited = {} - for index = 0, 99 do - local x = computed(function() - return mux()[index] - end) - splited[index] = computed(function() - return x() + 1 - end) - end - - for _, x in splited do - effect(function() - x() - end) - end - - bench.run("mux", function() - for i = 0, 9 do - heads[i](i) - assert(splited[i]() == i + 1) - end - for i = 0, 9 do - heads[i](i * 2) - assert(splited[i]() == i * 2 + 1) - end - end) -end diff --git a/package.json b/package.json index d0bf03d..c2815eb 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,29 @@ { "private": true, "scripts": { - "test": "lute run test", + "test": "lute run tests", "build": "lute run build", "check": "lute run check", "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, - "packageManager": "pnpm@12.8.1", + "packageManager": "pnpm@10.30.2", "devDependencies": { - "@eslint/js": "^9.39.4", + "@eslint/js": "^9.39.2", "@rbxts/compiler-types": "3.0.0-types.0", "@rbxts/react": "17.3.7-ts.1", "@rbxts/react-roblox": "17.3.7-ts.1", - "@rbxts/types": "^1.0.913", - "@rbxts/vide": "^0.6.1", - "@typescript-eslint/eslint-plugin": "^8.58.0", - "@typescript-eslint/parser": "^8.58.0", - "eslint": "^9.39.4", + "@rbxts/types": "^1.0.908", + "@rbxts/vide": "^0.6.0", + "@typescript-eslint/eslint-plugin": "^8.56.0", + "@typescript-eslint/parser": "^8.56.0", + "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", - "eslint-plugin-roblox-ts": "^1.4.0", + "eslint-plugin-roblox-ts": "^1.3.1", "prettier": "^3.8.1", "roblox-ts": "^3.0.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.58.0" + "typescript-eslint": "^8.56.0" } } diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index f2c84a3..db09da2 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.4.1", + "version": "0.4.0-rc.6", "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 973a983..165bbc6 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -3,7 +3,7 @@ local patch = require("./patch") local types = require("./types") type SyncPayload = types.SyncPayload -type ReactiveProxy = types.ReactiveProxy +type ReactiveProxy = types.ReactiveProxy local trackedSignals: { [string]: (any) -> any } = {} local trackedReactives: { [string]: ReactiveProxy } = {} @@ -64,8 +64,6 @@ local function applyInitPayload(data: { [string]: any }) trackedSignals[key](value) elseif trackedReactives[key] then shallowMergeReactive(trackedReactives[key], value) - else - warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`) end end end @@ -82,8 +80,6 @@ local function applyPatchPayload(data: { [string]: any }) elseif trackedReactive then patch.applyMutable(trackedReactive.__target, diffs) Charm.trigger(trackedReactive.__track) - else - warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`) end end end @@ -94,7 +90,7 @@ end @param payloads The state updates received from the server. ]] -local function applyPatches(payloads: { SyncPayload }) +local function patch(payloads: { SyncPayload }) for _, payload in payloads do if payload.type == "init" then Charm.batch(applyInitPayload, payload.data) @@ -108,5 +104,5 @@ return { addSignals = addSignals, removeSignals = removeSignals, removeAllSignals = removeAllSignals, - patch = applyPatches, + patch = patch, } diff --git a/packages/charm-sync/src/config.luau b/packages/charm-sync/src/config.luau index c3ce27c..a101f05 100644 --- a/packages/charm-sync/src/config.luau +++ b/packages/charm-sync/src/config.luau @@ -9,22 +9,19 @@ return { Whether the history of state changes since the client's last update should be preserved. This is useful for values that change multiple times per frame, where each individual change is important. Defaults - to `false` for performance reasons. + to `false`. ]] preserveHistory = false, --[[ - When `true`, Charm will modify sparse arrays to prevent data loss from - remote event limitations. When an unsafe array is detected, numeric - indices will be converted to string keys so that the array can be sent - safely and then converted back to an array on the receiving end. - + When `true`, Charm will apply validation and serialize unsafe arrays + to address remote event argument limitations. Defaults to `true`. This option should be disabled if your network library uses a custom serialization method (i.e. Zap, ByteNet) to prevent interference. ]] fixArrays = true, --[[ When `true`, synced state containing unsafe sparse arrays or mixed - tables will emit a warning. Only checked in strict mode and if + tables will throw an error. Only checked in strict mode and if `fixArrays` is enabled. Defaults to `true`. ]] validatePatches = true, diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index bb40eba..6519951 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -78,17 +78,14 @@ export const config: { * Whether the history of state changes since the client's last update * should be preserved. This is useful for values that change multiple * times per frame, where each individual change is important. Defaults - * to `false` for performance reasons. + * to `false`. * * @server */ preserveHistory: boolean; /** - * When `true`, Charm will modify sparse arrays to prevent data loss from - * remote event limitations. When an unsafe array is detected, numeric - * indices will be converted to string keys so that the array can be sent - * safely and then converted back to an array on the receiving end. - * + * When `true`, Charm will apply validation and serialize unsafe arrays + * to address remote event argument limitations. Defaults to `true`. * This option should be disabled if your network library uses a custom * serialization method (i.e. Zap, ByteNet) to prevent interference. * @@ -97,7 +94,7 @@ export const config: { fixArrays: boolean; /** * When `true`, synced state containing unsafe sparse arrays or mixed - * tables will emit a warning. Only checked in strict mode and if + * tables will throw an error. Only checked in strict mode and if * `fixArrays` is enabled. Defaults to `true`. * * @server @@ -245,7 +242,7 @@ export namespace patch { * @param statePatch The patches to apply. * @returns The new state with the patch applied. */ - export function applyImmutable( + export function apply( currentState: State, statePatch: SyncPatch, ): State; diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index d6b940f..b9a3be1 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]: any }): { [string | number]: any } +local function stringifySparseArray(object: {}): { [string | number]: any } local maxn = table.maxn(object) if maxn == 0 or maxn == #object then diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 6485e18..ce6fc9e 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -39,10 +39,9 @@ local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())? support custom implementations of reactive proxies. ]] local function toRaw(value: T): T - local proxy = value :: types.ReactiveProxy - if type(proxy) == "table" and type(proxy.__target) == "table" and type(proxy.__track) == "function" then - proxy.__track() - return toRaw(proxy.__target) + 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 @@ -61,7 +60,7 @@ local function trackSignal(key: string, getter: () -> T, identifier: unknown) local rawInitialValue = toRaw(initialValue) local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue - local dispose = Charm.untracked(Charm.effect, function() + local dispose = Charm.effect(function() local newValue = getter() local rawValue = toRaw(newValue) local isReactive = newValue ~= rawValue @@ -125,8 +124,8 @@ local function diffGlobalUpdateBuffer(): { any } local patchCount = #globalUpdates local patches = table.create(patchCount) - for index, update in globalUpdates do - patches[index] = patch.diff(update.oldValue, update.newValue) + for _, update in globalUpdates do + table.insert(patches, patch.diff(update.oldValue, update.newValue)) end return patches @@ -146,7 +145,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { for index, update in globalUpdates do local patchData = globalPatches[index] - if patchData == nil then + if not patchData then continue end @@ -154,17 +153,15 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { local status = clientKeys[key] if status == LISTENING_FOR_CHANGES then - local payload: SyncPayload = { + table.insert(payloads, { type = "patch", data = { [key] = patchData }, - } - table.insert(payloads, payload) + }) elseif status == PENDING_INITIAL_STATE then - local payload: SyncPayload = { + table.insert(payloads, { type = "init", data = { [key] = update.newValue }, - } - table.insert(payloads, payload) + }) clientKeys[key] = LISTENING_FOR_CHANGES end end @@ -231,19 +228,11 @@ local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any } end if next(initData) ~= nil then - local payload: SyncPayload = { - type = "init", - data = initData, - } - table.insert(payloads, payload) + table.insert(payloads, { type = "init", data = initData }) end if next(patchData) ~= nil then - local payload: SyncPayload = { - type = "patch", - data = patchData, - } - table.insert(payloads, payload) + table.insert(payloads, { type = "patch", data = patchData }) end return payloads diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau index 4a49871..a4dae84 100644 --- a/packages/charm-sync/src/utils.luau +++ b/packages/charm-sync/src/utils.luau @@ -2,6 +2,10 @@ local Charm = require("../Charm") type Atom = Charm.Atom +type NestedTable = { + [any]: NestedTable | any, +} + --[[ Flattens a nested table into a single table with slash-separated keys. Useful for recursively collecting atoms returned by modules. @@ -9,15 +13,15 @@ type Atom = Charm.Atom @param input A nested table. @return A flattened table with slash-separated keys. ]] -local function flatten(input: { [any]: any }): Flattened +local function flatten(input: {}): Flattened local result = {} - local function visit(node: { [any]: any }, path: string) + local function visit(node: {}, path: string) for key, value in node do local location = if path == "" then tostring(key) else path .. "/" .. tostring(key) if type(value) == "table" then - visit(value, location) + visit(value :: {}, location) else result[location] = value end diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau index dfba716..ac21161 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -1,11 +1,11 @@ -local function isTableUnsafe(object: { [any]: any }) - local firstKey = next(object) +local function isTableUnsafe(object: {}) + local key = next(object) - if firstKey == nil then + if key == nil then return false end - local keyType = type(firstKey) + local keyType = type(key) local maxKey = 0 -- Keys must all be the same type @@ -31,11 +31,13 @@ end with remote argument limitations, such as sparse arrays or mixed tables. @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! This can be for the following reasons:\n" + `Cannot sync tables unsupported by remote events! See the table below for more info.\n\n` + .. "This can be for the following reasons:\n" .. "1. The table is a sparse array with non-sequential indices\n" .. "2. The table is a mixed table (not all keys have the same type)\n\n" .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n", diff --git a/packages/charm-sync/test/patch.test.luau b/packages/charm-sync/test/patch.test.luau index 076544e..68d0483 100644 --- a/packages/charm-sync/test/patch.test.luau +++ b/packages/charm-sync/test/patch.test.luau @@ -2,7 +2,7 @@ local config = require("../src/config") local patch = require("../src/patch") local test = require("../../../tests/test") -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/test/server.test.luau b/packages/charm-sync/test/server.test.luau index d24970c..02a9af3 100644 --- a/packages/charm-sync/test/server.test.luau +++ b/packages/charm-sync/test/server.test.luau @@ -4,7 +4,6 @@ local config = require("../src/config") local patch = require("../src/patch") local server = require("../src/server") local test = require("../../../tests/test") -local types = require("../src/types") test.afterEach(function() server.reset() @@ -15,7 +14,7 @@ test("should replicate state changes", function() local a = Charm.atom(0) local b = Charm.atom(0) local c = Charm.atom(0) - local target: unknown, payloads: { types.SyncPayload } + local target, payloads server.addSignalsToClient(client, { a = a, b = b, c = c }) server.connect(function(...) @@ -42,7 +41,7 @@ test("should replicate partial state patches", function() local client = {} local a = Charm.atom({ x = 0, y = 0 }) local b = Charm.atom(0 :: number?) - local target: unknown, payloads: { types.SyncPayload } + local target, payloads server.addSignalsToClient(client, { a = a, b = b }) server.connect(function(...) @@ -69,7 +68,7 @@ end) test("should replicate partial state patches (reactive)", function() local client = {} local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? }) - local target: unknown, payloads: { types.SyncPayload } + local target, payloads server.addSignalsToClient(client, { a = a }) server.connect(function(...) @@ -119,7 +118,7 @@ test("should init new clients", function() local clientA = {} local clientB = {} local a = Charm.atom(0) - local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } + local payloadsA, payloadsB server.addSignalsToClient(clientA, { a = a }) server.connect(function(target, payloads) @@ -148,7 +147,7 @@ test("should init new atoms", function() local client = {} local a = Charm.atom(0) local b = Charm.atom(0) - local target: unknown, payloads: { types.SyncPayload } + local target, payloads server.addSignalsToClient(client, { a = a }) server.connect(function(...) @@ -173,7 +172,7 @@ test("should only replicate to synced clients", function() local clientB = {} local a = Charm.atom(0) local b = Charm.atom(0) - local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } + local payloadsA, payloadsB server.addSignalsToClient(clientA, { a = a }) server.addSignalsToClient(clientB, { b = b }) @@ -215,7 +214,7 @@ end) test("should serialize state", function() local client = {} local a = Charm.atom({ 1, 2, 3, 4, 5 }) - local payloads: { types.SyncPayload } + local payloads server.addSignalsToClient(client, { a = a }) server.connect(function(_, p) @@ -232,7 +231,7 @@ end) test("should not send unchanged state", function() local client = {} local a = Charm.atom(0) - local payloads: { types.SyncPayload } + local payloads server.addSignalsToClient(client, { a = a }) server.connect(function(_, p) @@ -263,7 +262,7 @@ test.describe("historical", function() local a = Charm.atom(0) local b = Charm.atom(0) local c = Charm.atom(0) - local target: unknown, payloads: { types.SyncPayload } + local target, payloads server.addSignalsToClient(client, { a = a, b = b, c = c }) server.connect(function(...) @@ -293,7 +292,7 @@ test.describe("historical", function() local a = DeepCharm.reactive({ value = 0 }) local b = DeepCharm.reactive({ value = 0 }) local c = DeepCharm.reactive({ value = 0 }) - local target: unknown, payloads: { types.SyncPayload } + local target, payloads server.addSignalsToClient(client, { a = a, b = b, c = c }) server.connect(function(...) @@ -322,7 +321,7 @@ test.describe("historical", function() local clientA = {} local clientB = {} local a = Charm.atom(0) - local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } + local payloadsA, payloadsB server.addSignalsToClient(clientA, { a = a }) server.connect(function(target, payloads) @@ -358,7 +357,7 @@ test.describe("historical", function() local client = {} local a = Charm.atom(0) local b = Charm.atom(0) - local payloads: { types.SyncPayload } + local payloads server.addSignalsToClient(client, { a = a }) server.connect(function(_, ...) @@ -387,7 +386,7 @@ test.describe("historical", function() test("should not send unchanged state", function() local client = {} local a = Charm.atom(0) - local payloads: { types.SyncPayload } + local payloads server.addSignalsToClient(client, { a = a }) server.connect(function(_, p) @@ -403,54 +402,6 @@ test.describe("historical", function() server.flush() assert(#payloads == 0, "Expected no payloads to be sent for unchanged state") end) - - test("should maintain patch list order for stale update", function() - local a = Charm.atom({ x = 1 }) - local b = Charm.atom({ y = 1 }) - local payloads - - server.connect(function(_client, _payloads) - payloads = _payloads - end) - - server.addSignalsToClient("client", { a = a, b = b }) - server.flush() - - payloads = nil - a({ x = 1 }) -- new table, same contents: diff is nil - b({ y = 2 }) - server.flush() - assert(payloads, "Expected payloads to be sent") - assert(payloads[1].type == "patch", "Expected payload to be patch") - assert(payloads[1].data.b.y == 2, "Expected payload to contain atom 'b'") - end) -end) - -test("addSignalsToClient should ignore tracking context", function() - local counter = Charm.atom(0) - local payloads: { any }? - - server.connect(function(_client, _payloads) - payloads = _payloads - end) - - local dispose = Charm.effectScope(function() - server.addSignalsToClient("PLAYER_ID", { counter = counter }) - end) - - server.flush() - assert(payloads, "Expected payloads to be sent") - assert(payloads[1].type == "init", "Expected first payload to be init") - assert(payloads[1].data.counter == 0, "Expected init payload to contain atom 'counter'") - - dispose() - - payloads = nil - counter(1) - server.flush() - assert(payloads, "Expected payloads to be sent") - assert(payloads[1].type == "patch", "Expected first payload to be patch") - assert(payloads[1].data.counter == 1, "Expected patch payload to contain atom 'counter'") end) return {} diff --git a/packages/charm-sync/test/utils.test.luau b/packages/charm-sync/test/utils.test.luau index 1d50f55..14c9734 100644 --- a/packages/charm-sync/test/utils.test.luau +++ b/packages/charm-sync/test/utils.test.luau @@ -6,10 +6,9 @@ test("should flatten functions", function() local input = { a = Charm.atom(0), b = { c = Charm.atom(0) }, - d = { e = { f = utils.signalToAtom<>(Charm.signal(0)) } }, + d = { e = { f = utils.signalToAtom(Charm.signal(0)) } }, } - - local result = utils.flatten<<{ [string]: Charm.Atom }>>(input) + local result = utils.flatten(input) assert(result.a == input.a, "should flatten the first level") assert(result["b/c"] == input.b.c, "should flatten the second level") diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index 589e7ed..7fc9779 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm-sync" -version = "0.4.1" +version = "0.4.0-rc.6" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -12,4 +12,4 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0" +Charm = "littensy/charm@^0.11.0-rc.5" diff --git a/packages/charm/package.json b/packages/charm/package.json index 7ef2e29..e0701d7 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.11.1", + "version": "0.11.0-rc.5", "description": "Atomic state management based on alien-signals", "license": "MIT", "main": "src/init.luau", diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index e4689e5..ae37672 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -50,12 +50,12 @@ export const flags: { /** * Enforces synchronous, non-yielding behavior in signals and effects. * Also enables state validation in Charm Sync to catch sync errors early. - * Enabled by default in Roblox Studio. + * Enabled if the optimization level is below 2, which is true in studio. */ strict: boolean; /** * Enforces immutability of tables by deep-freezing table values. Enabled - * by default in Roblox Studio. + * if the optimization level is below 2, which is true in studio. */ frozen: boolean; /** @@ -179,50 +179,53 @@ export function batch(fn: (...args: Args) => Result, * when the value returned by the getter changes. The callback receives the new * value and the previous value as arguments. * + * Effects created within the callback are cleaned up when the listener runs + * or the listener is disposed. + * * @param getter A function that returns the value to watch. - * @param callback The function to run when the value changes. + * @param fn The function to run when the value changes. * @returns A function for disposing the effect. * @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback */ -export function listen(getter: Getter, callback: (value: T, previousValue: T | undefined) => void): Cleanup; +export function listen(getter: Getter, fn: (value: T, previousValue: T | undefined) => void): Cleanup; /** * Creates an effect that only runs the callback when the value returned by the * getter changes. The callback receives the new value and the previous value * as arguments. * + * Effects created within the callback are cleaned up when the listener runs + * or the listener is disposed. + * * @param getter A function that returns the value to watch. - * @param callback The function to run when the value changes. + * @param fn The function to run when the value changes. * @returns A function for disposing the effect. * @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback */ -export function subscribe(getter: Getter, callback: (value: T, previousValue: T) => void): Cleanup; +export function subscribe(getter: Getter, fn: (value: T, previousValue: T) => void): Cleanup; /** * Creates a new read-only signal that is computed by mapping over the entries - * of the source table. If the key is omitted from the transform function's - * return value, the original key is preserved. + * of an existing atom. If the key is omitted from the mapper's return value, + * the original key is preserved. * - * This function is optimized to minimize unnecessary updates by only calling - * the transform function for entries that have changed since the last run. - * - * @param getter A signal or function that returns a table - * @param transform Function that maps each entry from the source to a new entry in the mapped table - * @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform + * @param getter Returns the table to map over. + * @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. + * @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper */ export function mapped( getter: Getter | ReadonlyMap>, - transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, + mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, ): Getter>; // Overload for arrays export function mapped( getter: Getter, - transform: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, + mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, ): Getter; // Overload for objects export function mapped( getter: Getter<{ readonly [K in KI]: VI }>, - transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, + mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, ): Getter<{ readonly [K in KO]: VO }>; /** @@ -237,18 +240,18 @@ export function mapped( * from the table or when the callback is disposed. * * @param getter A function that returns the table to observe. - * @param callback A function that is called for each key in the table. + * @param fn A function that is called for each key in the table. * @returns A function for disposing the observer. * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback */ export function observe( getter: Getter | ReadonlyMap | readonly V[]>, - callback: (value: V, key: K) => Cleanup | void, + fn: (value: V, key: K) => Cleanup | void, ): Cleanup; // Overload for objects export function observe( getter: Getter<{ readonly [K in Key]: V }>, - callback: (value: V, key: K) => Cleanup | void, + fn: (value: V, key: K) => Cleanup | void, ): Cleanup; /** diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 0ba77d7..c2c9275 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -45,11 +45,10 @@ type SignalNode = ReactiveNode & { -- Bitmask flags for tracking the state of reactive nodes. Flags are combined -- and checked using bitwise operations for efficiency. local NONE = 0b000000 -- Default state -local MUTABLE = 0b000001 -- Can be written to (signal or computed) -local WATCHING = 0b000010 -- Notified for changes in dependencies (effects) +local MUTABLE = 0b000001 -- Can be changed (signal or computed) +local WATCHING = 0b000010 -- Watching for changes (effects) local RECURSED_CHECK = 0b000100 -- Being checked for circular dependencies -local RECURSED = 0b001000 -- Has been visited in a recursion check -local DIRTY = 0b010000 -- Value is outdated and needs recomputation +local DIRTY = 0b010000 -- Value has changed and needs updating local PENDING = 0b100000 -- Might be dirty, pending verification -- Global version counter for dependency link deduplication. Incremented at the @@ -58,7 +57,7 @@ local PENDING = 0b100000 -- Might be dirty, pending verification local cycle = 0 -- Effects runs are queued to prevent redundant executions when multiple -- signals update in the same batch. -local queued: { EffectNode } = {} +local queued: { EffectNode? } = {} local queuedLength = 0 local notifyIndex = 0 -- Batch operations group multiple updates together, deferring effect runs @@ -68,22 +67,22 @@ local batchDepth = 0 -- Dependencies accessed during this time will link to the active subscriber. local activeSub: ReactiveNode? -local function isStudio(): boolean - return game ~= nil and game:GetService("RunService"):IsStudio() +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 by default in Roblox Studio. + Enabled if the optimization level is below 2, which is true in studio. ]] - strict = isStudio(), + strict = not isO2(), --[[ Enforces immutability of tables by deep-freezing table values. Enabled - by default in Roblox Studio. + if the optimization level is below 2, which is true in studio. ]] - frozen = isStudio(), + 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 @@ -93,44 +92,37 @@ local flags = { } --[[ - Wraps a user-created function to prevent yielding when in strict mode. To - shorten error stack traces, only functions created outside of this module - are wrapped. + Wraps a function to prevent yielding when in strict mode. If the function + attempts to yield, an error is thrown. + + @param fn The function to wrap. + @return A wrapped function that cannot yield in strict mode. ]] -local function wrapUserSpace(callback: (Args...) -> Results...): (Args...) -> Results... +local function noYield(fn: (Args...) -> Results...): (Args...) -> Results... if not flags.strict then - return callback + return fn end - -- If the sources match, the function was created in this module - if debug.info(1, "s") == debug.info(callback, "s") then - return callback - end - - local handler = function(thread: thread, ok: boolean, ...: Results...): Results... + local function resultHandler(thread: thread, ok: boolean, ...) if not ok then - local err = (...) - if type(err) == "string" then - error(debug.traceback(thread, err), 0) - else - error(tostring(err), 0) - end + error(debug.traceback(thread, tostring((...)))) elseif coroutine.status(thread) ~= "dead" then - error(debug.traceback(thread, "Attempted to yield in an effect or scope"), 0) - else - return ... + error(debug.traceback(thread, "Yielding is not allowed in strict mode")) end + return ... end - return function(...: Args...): Results... - local thread = coroutine.create(callback) - return handler(thread, coroutine.resume(thread, ...)) + return function(...) + local thread = coroutine.create(fn) + return resultHandler(thread, coroutine.resume(thread, ...)) end end --[[ Recursively freezes a table and all nested tables to enforce immutability. Tables with metatables are skipped, as they are assumed to be mutable. + + @param value Table to freeze ]] local function deepFreeze(value: any) if type(value) == "table" and not table.isfrozen(value) and getmetatable(value) == nil then @@ -143,6 +135,8 @@ end --[[ Returns the currently active subscriber (effect or computed node). + + @return Current active subscriber or `nil` ]] local function getActiveSub(): ReactiveNode? return activeSub @@ -151,6 +145,9 @@ end --[[ Sets the currently active subscriber (effect or computed node) and returns the previous subscriber. + + @param sub New subscriber to set as active + @return Previous active subscriber ]] local function setActiveSub(sub: ReactiveNode?) local prevSub = activeSub @@ -162,6 +159,8 @@ end Removes dependencies from the subscriber's dependency list. When called after tracking is complete, this ensures that only currently tracked dependencies remain linked. + + @param sub Effect or computed node ]] local function purgeDeps(sub: ReactiveNode) local depsTail = sub.depsTail @@ -173,49 +172,33 @@ end --[[ Runs all cleanup functions registered with the effect node. Any errors - thrown during cleanup are caught and logged in a single error after all - cleanups are complete. + thrown during cleanup are caught and logged as warnings. + + @param cleanups List of cleanup functions ]] local function runCleanups(cleanups: { Cleanup }) - local hasErrors = false - local errors: { string } = {} - - for index, cleanup in cleanups do - cleanups[index] = nil - + for key, cleanup in cleanups do local prevSub = setActiveSub(nil) - local success, err = pcall(cleanup) + cleanups[key] = nil + xpcall(cleanup, function(err) + warn("Error occurred during effect cleanup:", debug.traceback(tostring(err), 2)) + end) activeSub = prevSub - - if not success then - hasErrors = true - table.insert(errors, tostring(err)) - end - end - - if hasErrors then - error(`Errors occurred during effect cleanup:\n\n{table.concat(errors, "\n\n")}`, 0) end end --[[ Checks if the effect node needs to run based on its flags and dependencies. Effects run if they are marked dirty or pending with dirty dependencies. + + @param effect Effect node to run ]] local function run(effect: EffectNode) - local effectFlags = effect.flags + local flags = effect.flags - if - bit32.btest(effectFlags, DIRTY) - or (bit32.btest(effectFlags, PENDING) and checkDirty(effect.deps :: Link, effect)) - then + if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then runCleanups(effect.cleanups) - -- Cancel the run if the effect was disposed during cleanup - if effect.flags == NONE then - return - end - cycle += 1 effect.depsTail = nil effect.flags = WATCHING @@ -226,16 +209,10 @@ local function run(effect: EffectNode) purgeDeps(effect) - if success and result then - table.insert(effect.cleanups, wrapUserSpace(result)) - elseif not success then - error(result, 0) - end - - -- If the effect was disposed during execution but before a cleanup was - -- was registered, run the cleanups that would have been missed - if effect.flags == NONE then - runCleanups(effect.cleanups) + if not success then + error(result) + elseif result then + table.insert(effect.cleanups, noYield(result)) end else effect.flags = WATCHING @@ -247,35 +224,15 @@ end queued, ensuring that all updates are processed. ]] local function flush() - -- Bail out early if a flush is already in progress to allow effects to - -- complete before the next effect runs - if batchDepth ~= 0 or notifyIndex ~= 0 then - return - end - - local success, err = pcall(function() - while notifyIndex < queuedLength do - notifyIndex += 1 - local effect = queued[notifyIndex] :: EffectNode - queued[notifyIndex] = nil - run(effect) - end - end) - - -- Skip over any remaining queued effects that were not run due to an error while notifyIndex < queuedLength do - notifyIndex += 1 local effect = queued[notifyIndex] :: EffectNode queued[notifyIndex] = nil - effect.flags = bit32.bor(effect.flags, bit32.bor(WATCHING, RECURSED)) + notifyIndex += 1 + run(effect) end notifyIndex = 0 queuedLength = 0 - - if not success then - error(err, 0) - end end --[[ @@ -292,12 +249,17 @@ end ]] local function endBatch() batchDepth -= 1 - flush() + if batchDepth == 0 then + flush() + end end --[[ Updates a computed signal by re-evaluating its getter function. Returns whether the computed value changed. + + @param computed Computed node to update + @return Boolean indicating whether the computed value changed ]] local function updateComputed(computed: ComputedNode): boolean cycle += 1 @@ -316,13 +278,16 @@ local function updateComputed(computed: ComputedNode): boolean computed.value = newValue return oldValue ~= newValue else - error(newValue, 0) + error(newValue) end end --[[ Updates a signal by applying its pending value. Returns whether the signal's value changed. + + @param signal Signal node to update + @return Boolean indicating whether the signal's value changed ]] local function updateSignal(signal: SignalNode): boolean signal.flags = MUTABLE @@ -336,27 +301,45 @@ local function updateSignal(signal: SignalNode): boolean end --[[ - Clears all dependencies and subscribers from the effect node. After calling - this, the node will no longer be subscribed to any dependencies and will be - inactive, with all cleanup functions run and resources released. + Clears the pending flag from a subscriber. Used inside of if conditions to + inline the operation. + + @param sub Effect or computed node + @param flags Subscriber's current flags ]] -local function stopEffect(effect: EffectScopeNode) - effect.depsTail = nil - effect.flags = NONE +local function clearPending(sub: ReactiveNode, flags: ReactiveFlags) + sub.flags = bit32.band(flags, bit32.bnot(PENDING)) +end - purgeDeps(effect) +--[[ + Performs a thorough cleanup of the given effect or effect scope: - -- Unlink from the parent effect, if it exists - local sub = effect.subs + 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 stopEffect(this: EffectScopeNode) + this.depsTail = nil + this.flags = NONE + + purgeDeps(this) + + local sub = this.subs if sub then - unlink(sub, effect) + unlink(sub, this) end - runCleanups(effect.cleanups) + runCleanups(this.cleanups) 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 @@ -369,20 +352,19 @@ 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 startIndex = queuedLength + 1 + local insertIndex = queuedLength + local firstInsertedIndex = insertIndex -- Collect inner effects in a chain repeat - queuedLength += 1 - queued[queuedLength] = effect :: EffectNode + queued[insertIndex] = effect :: EffectNode + insertIndex += 1 - -- Mark as not watching to prevent duplicates in the same flush effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING)) - - -- Traverse to the parent effect, if it exists and is not queued, to - -- ensure it gets notified before the child effect effect = (effect.subs and effect.subs.sub) :: EffectNode if not effect or bit32.band(effect.flags, WATCHING) == 0 then @@ -390,14 +372,16 @@ local function notify(effect: ReactiveNode) end until false - local endIndex = queuedLength + queuedLength = insertIndex - -- Reverse the collected effects since they were collected from child to - -- parent, but should be notified from parent to child - while startIndex < endIndex do - queued[startIndex], queued[endIndex] = queued[endIndex], queued[startIndex] - startIndex += 1 - endIndex -= 1 + -- 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 @@ -405,9 +389,10 @@ 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 + - 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 @@ -430,7 +415,7 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals ]] local function signal(initialValue: T, equals: Equals?): (Getter, Setter) - local node: SignalNode = { + local this: SignalNode = { currentValue = initialValue, pendingValue = initialValue, flags = MUTABLE, @@ -440,71 +425,74 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette deepFreeze(initialValue) end - local function signalGetter(): T - -- Check if the signal has a pending update that needs to be applied - if bit32.btest(node.flags, DIRTY) then - if updateSignal(node) then - local subs = node.subs - if subs then - shallowPropagate(subs) - end - end - end - - -- Register this signal as a dependency of the first subscriber in the - -- chain that is a computed signal or an effect - local sub = activeSub - while sub do - if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then - link(node, sub, cycle) - break - end - local subs = sub.subs - sub = if subs then subs.sub else nil - end - - return node.currentValue - end - - local function signalSetter(update: Update): T + local function writeSignal(update: Update): T local value: T -- Determine the new value based on whether the update is a function or -- a direct value if type(update) == "function" then local prevSub = setActiveSub(nil) - local success, result = pcall(wrapUserSpace(update), node.pendingValue) + local success, result = pcall(noYield(update), this.pendingValue) activeSub = prevSub if success then value = result else - error(result, 2) + error(result) end else value = update end -- Check if the new value is different from the pending value - if if equals then not equals(node.pendingValue, value :: T) else node.pendingValue ~= value then + if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then if flags.frozen then deepFreeze(value) end - node.pendingValue = value - node.flags = bit32.bor(MUTABLE, DIRTY) + this.pendingValue = value + this.flags = bit32.bor(MUTABLE, DIRTY) - local subs = node.subs + local subs = this.subs if subs then propagate(subs) - flush() + if batchDepth == 0 then + flush() + end + end + + return value + end + + return this.currentValue + end + + local function readSignal(): T + -- Check if the signal needs to be updated (for signals within effects) + if bit32.btest(this.flags, DIRTY) then + if updateSignal(this) then + local subs = this.subs + if subs then + shallowPropagate(subs) + end end end - return node.pendingValue + -- Register this signal as a dependency of the active subscriber + local sub = activeSub + while sub do + if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then + link(this, sub, cycle) + break + end + local subs = sub.subs + sub = if subs then subs.sub else nil + end + + return this.currentValue end - return signalGetter, signalSetter + return readSignal, writeSignal end --[[ @@ -518,11 +506,9 @@ end ]] local function atom(initialValue: T, equals: Equals?): Atom local get, set = signal(initialValue, equals) - local function atomOper(...) return if select("#", ...) == 0 then get() else set((...)) end - return atomOper end @@ -536,57 +522,50 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter ]] local function computed(getter: (previousValue: T?) -> T): Getter - local node: ComputedNode = { + local this: ComputedNode = { flags = NONE, - getter = wrapUserSpace(getter), + getter = noYield(getter), } - local function computedOper(): T - local nodeFlags = node.flags + local function readComputed(): T + local flags = this.flags - if bit32.btest(nodeFlags, DIRTY) then - if updateComputed(node) then - local subs = node.subs + 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 bit32.btest(nodeFlags, PENDING) then - if checkDirty(node.deps :: Link, node) then - if updateComputed(node) then - local subs = node.subs - if subs then - shallowPropagate(subs) - end - end - else - node.flags = bit32.band(nodeFlags, bit32.bnot(PENDING)) - end - elseif nodeFlags == NONE then + elseif flags == NONE then -- First access should initialize the computed value - node.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) - local prevSub = setActiveSub(node) - local success, result = pcall(node.getter) + local prevSub = setActiveSub(this) + local success, result = pcall(this.getter) activeSub = prevSub - node.flags = bit32.band(node.flags, bit32.bnot(RECURSED_CHECK)) + this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK)) if success then - node.value = result + this.value = result else - error(result, 2) + error(result) end end if activeSub then - link(node, activeSub, cycle) + link(this, activeSub, cycle) end - return node.value + return this.value :: T end - return computedOper + return readComputed end --[[ @@ -605,38 +584,28 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback ]] local function effect(fn: () -> ...Cleanup?): Cleanup - local node: EffectNode = { - fn = wrapUserSpace(fn), + local this: EffectNode = { + fn = noYield(fn), flags = WATCHING, cleanups = {}, } - -- Inner effects are tracked as a dependency of the parent effect, so that - -- they can be automatically cleaned up when the parent effect re-runs, and - -- to ensure the correct execution order when nested effects are notified if activeSub and (flags.trackInnerEffects or activeSub.flags == NONE) then - link(node, activeSub, 0) + link(this, activeSub, 0) end - -- Start a batch so that the effect can register a cleanup function before - -- it can receive notifications, ensuring the cleanup always runs - startBatch() - - local prevSub = setActiveSub(node) - local success, result = pcall(node.fn) + local prevSub = setActiveSub(this) + local success, result = pcall(this.fn) activeSub = prevSub - if success and result then - table.insert(node.cleanups, wrapUserSpace(result)) - elseif not success then - endBatch() - error(result, 2) + if not success then + error(result) + elseif result then + table.insert(this.cleanups, noYield(result)) end - endBatch() - return function() - stopEffect(node) + stopEffect(this) end end @@ -645,33 +614,32 @@ end signals created within it so that they can be disposed together. @param fn Function that creates effects within the scope - @param detached If true, the scope will not be tracked by the parent effect or scope + @param detached If true, the scope will not be tracked by the parent effect/scope @return A cleanup function that stops all effects in the scope @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached ]] local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup - local node: EffectScopeNode = { + local this: EffectScopeNode = { flags = NONE, cleanups = {}, } - -- Inner effect scopes are tracked as a dependency of the parent effect if activeSub and not detached then - link(node, activeSub, 0) + link(this, activeSub, 0) end - local prevSub = setActiveSub(node) - local success, result = pcall(wrapUserSpace(fn)) + local prevSub = setActiveSub(this) + local success, result = pcall(noYield(fn)) activeSub = prevSub - if success and result then - table.insert(node.cleanups, wrapUserSpace(result)) - elseif not success then - error(result, 2) + if not success then + error(result) + elseif result then + table.insert(this.cleanups, noYield(result)) end return function() - stopEffect(node) + stopEffect(this) end end @@ -682,31 +650,33 @@ end @param fn A signal or function that accesses signals @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback ]] -local function trigger(fn: () -> ...any) +local function trigger(fn: () -> ()) local sub: ReactiveNode = { flags = WATCHING, } local prevSub = setActiveSub(sub) - local success, err = pcall(wrapUserSpace(fn)) + local success, result: any = pcall(noYield(fn)) activeSub = prevSub - sub.flags = NONE - local currentLink = sub.deps - while currentLink do - local dep = currentLink.dep + if not success then + error(result) + end + + local link = sub.deps + while link do + local dep = link.dep + link = unlink(link, sub) local subs = dep.subs - currentLink = unlink(currentLink, sub) if subs then + sub.flags = NONE propagate(subs) shallowPropagate(subs) end end - flush() - - if not success then - error(err, 2) + if batchDepth == 0 then + flush() end end @@ -716,12 +686,12 @@ end set to `true`. @param fn Function to run during cleanup - @param failSilently If true, silences the warning when called outside an effect or scope + @param failSilently If true, silences the warning when called outside an effect/scope @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently ]] local function onCleanup(fn: Cleanup, failSilently: boolean?) if activeSub and (activeSub :: EffectNode).cleanups then - table.insert((activeSub :: EffectNode).cleanups, wrapUserSpace(fn)) + table.insert((activeSub :: EffectNode).cleanups, noYield(fn)) elseif not failSilently then warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) end @@ -743,13 +713,11 @@ end local function untracked(fn: (Args...) -> Results..., ...: Args...): Results... if activeSub then local prevSub = setActiveSub(nil) - local results: { any } = { pcall(wrapUserSpace(fn), ...) } + local results: { any } = { pcall(noYield(fn), ...) } activeSub = prevSub - if not results[1] then - error(results[2], 2) + error(results[2]) end - return unpack(results, 2) else return fn(...) @@ -767,44 +735,48 @@ end ]] local function batch(fn: (Args...) -> Results..., ...: Args...): Results... startBatch() - local results: { any } = { pcall(wrapUserSpace(fn), ...) } + local results: { any } = { pcall(noYield(fn), ...) } endBatch() - if not results[1] then - error(results[2], 2) + error(results[2]) end - return unpack(results, 2) end --[[ - Returns true if the given function is a signal getter, computed signal, or - atom function. Used for determining whether a function should be wrapped in - a computed signal so that it can be memoized in `listen()` and `subscribe()`. + Wraps a function in a computed signal if it is not already a reactive + signal. Useful for memoizing a getter function without creating unnecessary + nested signals. + + @param fn A signal or function that accesses signals + @return `fn` wrapped in a computed signal if needed ]] -local function isSignal(fn: Getter): boolean +local function toComputed(fn: Getter): Getter local name = debug.info(fn, "n") - return name == "signalGetter" or name == "computedOper" or name == "atomOper" + return if name == "readSignal" or name == "readComputed" or name == "atomOper" then fn else computed(fn) end --[[ - Creates an effect that runs the callback once immediately, and then again + Creates an effect that runs the callback one immediately, and then again when the value returned by the getter changes. The callback receives the new value and the previous value as arguments. + Effects created within the callback are cleaned up when the listener runs + or the listener is disposed. + @param getter A signal or function that accesses signals - @param callback Function to run when the value changes + @param fn Function to run when the value changes @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback ]] -local function listen(getter: Getter, callback: (state: T, lastState: T?) -> ()): Cleanup - local tracked: Getter = if isSignal(getter) then getter else computed(getter :: (T?) -> T) +local function listen(getter: Getter, fn: (state: T, lastState: T?) -> ()): Cleanup + local tracked = toComputed(getter) local current: T return effect(function() local previous = current current = tracked() - untracked(callback, current, previous) + untracked(fn, current, previous) end) end @@ -813,13 +785,16 @@ end the getter changes. The callback receives the new value and the previous value as arguments. + Effects created within the callback are cleaned up when the listener runs + or the listener is disposed. + @param getter A signal or function that accesses signals - @param callback Function to run when the value changes + @param fn Function to run when the value changes @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback ]] -local function subscribe(getter: Getter, callback: (state: T, lastState: T) -> ()): Cleanup - local tracked: Getter = if isSignal(getter) then getter else computed(getter :: (T?) -> T) +local function subscribe(getter: Getter, fn: (state: T, lastState: T) -> ()): Cleanup + local tracked = toComputed(getter) local current: T local firstRun = true @@ -829,68 +804,65 @@ local function subscribe(getter: Getter, callback: (state: T, lastState: T if firstRun then firstRun = false else - untracked(callback, current, previous) + untracked(fn, current, previous) end end) end --[[ - Calls the callback function for each unique key in the table returned by - the getter. The callback is called once for each key, and again when a new - key is added. + Calls the callback function for each unique key in the table returned by the + getter. The callback is called once for each key, and again when a new key + is added. The callback may return a cleanup function, which will be called when the key is removed or the `observe()` effect is disposed. Effects created during the callback will also be bound to the lifetime of the key. @param getter A signal or function that returns a table - @param callback Function that is called for each key added to the table + @param fn Function that is called for each key added to the table @return A cleanup function that disposes scopes for all keys @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback ]] -local function observe(getter: Getter<{ [K]: V }>, callback: (item: V, key: K) -> ...Cleanup?): Cleanup - getter = wrapUserSpace(getter) - callback = wrapUserSpace(callback) - - local scopes: { [K]: Cleanup } = {} - local stopObserving = false +local function observe(getter: Getter<{ [K]: V }>, fn: (item: V, key: K) -> ...Cleanup?): Cleanup + local innerScopes: { [K]: Cleanup } = {} + local disposed = false local function updateScopes(data: { [K]: V }) - for key, dispose in scopes do + for key, dispose in innerScopes do if data[key] == nil then - scopes[key] = nil + innerScopes[key] = nil dispose() - if stopObserving then + if disposed then return end end end for key, value in data do - if scopes[key] == nil then - local dispose = effectScope(function() - return callback(value, key) + if innerScopes[key] == nil then + local disposeScope = effectScope(function() + return fn(value, key) end, true) - if stopObserving then - dispose() - return + if disposed then + return disposeScope() end - scopes[key] = dispose + innerScopes[key] = disposeScope end end end return effectScope(function() effect(function() - -- Wrap in untracked so that calling fn inside updateScopes does - -- not register dependencies to this effect - untracked(updateScopes, getter()) + -- Batch so that side effects run after all scopes are created. + -- This ensures that a scope creating or removing items doesn't + -- interfere with the current update cycle. + batch(updateScopes, getter()) end) return function() - stopObserving = true - for key, dispose in scopes do - scopes[key] = nil + disposed = true + for key, dispose in innerScopes do + innerScopes[key] = nil dispose() end end @@ -899,76 +871,65 @@ end --[[ Creates a new read-only signal that is computed by mapping over the entries - of the source table. If the key is omitted from the transform function's - return value, the original key is preserved. - - This function is optimized to minimize unnecessary updates by only calling - the transform function for entries that have changed since the last run. + of an existing atom. If the key is omitted from the mapper's return value, + the original key is preserved. @param getter A signal or function that returns a table - @param transform Function that maps each entry from the source to a new entry in the mapped table - @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform + @param mapper Function that maps each entry from the source to a new entry in the mapped table + @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper ]] -local function mapped(getter: Getter<{ [KI]: VI }>, transform: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> - getter = wrapUserSpace(getter) - transform = wrapUserSpace(transform) +local function mapped(getter: Getter<{ [KI]: VI }>, mapper: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> + local currentInput: { [KI]: VI } = {} + local keyCache: { [KI]: KO } = {} - local nextInput: { [KI]: VI } = {} - local keyMap: { [KI]: KO } = {} + local function map(value: VI, key: KI): (VO, KO) + local prevSub = setActiveSub(nil) + local success, valueOut, keyOut = pcall(mapper, value, key) + activeSub = prevSub + if not success then + error(`mapped() transform function failed: {valueOut}`) + end + return valueOut, if keyOut == nil then key else keyOut :: any + end - return computed(function(lastOutput: { [KO]: VO }?): { [KO]: VO } - local nextOutput: { [KO]: VO } = lastOutput or {} - local prevInput = nextInput - nextInput = getter() + return computed(function(previousOutput) + local currentOutput: { [KO]: VO } = previousOutput or {} + local previousInput = currentInput + currentInput = getter() - -- For keys that were removed from the input, remove the corresponding - -- keys from the output. - for keyIn in prevInput do - if nextInput[keyIn] ~= nil then + for keyIn in previousInput do + if currentInput[keyIn] ~= nil then continue end - local keyOut = keyMap[keyIn] + local prevKeyOut = keyCache[keyIn] - if keyOut ~= nil then - if nextOutput == lastOutput then - nextOutput = table.clone(nextOutput) + if prevKeyOut then + if currentOutput == previousOutput then + currentOutput = table.clone(currentOutput) end - nextOutput[keyOut] = nil - keyMap[keyIn] = nil + currentOutput[prevKeyOut] = nil + keyCache[keyIn] = nil end end - -- For keys that are new or changed, pass it to the mapper and update - -- the output. If the mapper returns a new key that is different from - -- the previous call, the old mapped key should be removed from the - -- output. - for keyIn, nextValueIn in nextInput do - if nextValueIn == prevInput[keyIn] then + for keyIn, valueIn in currentInput do + if valueIn == previousInput[keyIn] then continue end - local nextValueOut, keyOutOrNil = transform(nextValueIn, keyIn) - local nextKeyOut: KO = if keyOutOrNil ~= nil then keyOutOrNil else keyIn - local prevKeyOut = keyMap[keyIn] + local valueOut, keyOut = map(valueIn, keyIn) - if prevKeyOut ~= nil and prevKeyOut ~= nextKeyOut then - if nextOutput == lastOutput then - nextOutput = table.clone(nextOutput) + if currentOutput[keyOut] ~= valueOut then + if currentOutput == previousOutput then + currentOutput = table.clone(currentOutput) end - nextOutput[prevKeyOut] = nil - nextOutput[nextKeyOut] = nextValueOut - keyMap[keyIn] = nextKeyOut - elseif nextOutput[nextKeyOut] ~= nextValueOut then - if nextOutput == lastOutput then - nextOutput = table.clone(nextOutput) - end - nextOutput[nextKeyOut] = nextValueOut - keyMap[keyIn] = nextKeyOut + currentOutput[keyOut] = valueOut + keyCache[keyIn] = keyOut end end - return nextOutput + return currentOutput end) end @@ -990,8 +951,8 @@ return { observe = observe, mapped = mapped :: (( getter: Getter<{ [KI]: VI }>, - transform: (VI, KI) -> (VO, KO) - ) -> Getter<{ [KO]: VO }>) & ((getter: Getter<{ [K]: VI }>, transform: (VI, K) -> VO) -> Getter<{ [K]: VO }>), + mapper: (VI, KI) -> (VO, KO) + ) -> Getter<{ [KO]: VO }>) & ((getter: Getter<{ [K]: VI }>, mapper: (VI, K) -> VO) -> Getter<{ [K]: VO }>), onCleanup = onCleanup, getActiveSub = getActiveSub, setActiveSub = setActiveSub, diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau index a01aa5e..10ad273 100644 --- a/packages/charm/src/system.luau +++ b/packages/charm/src/system.luau @@ -102,7 +102,7 @@ local function link(dep: ReactiveNode, sub: ReactiveNode, version: number) end local function unlink(link: Link, sub: ReactiveNode?): Link? - sub = sub or link.sub + local sub = sub or link.sub local dep = link.dep local prevDep = link.prevDep local nextDep = link.nextDep @@ -149,6 +149,7 @@ local function propagate(link: Link) sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING)) flags = bit32.band(flags, MUTABLE) else + -- selene: allow(if_same_then_else) flags = NONE end diff --git a/packages/charm/test/computed.test.luau b/packages/charm/test/computed.test.luau index c0095cf..4590807 100644 --- a/packages/charm/test/computed.test.luau +++ b/packages/charm/test/computed.test.luau @@ -84,7 +84,7 @@ end) test("should receive previous value", function() local calls = {} local src, setSrc = signal(0) - local c1 = computed(function(current: number?): number + local c1 = computed(function(current): number local incoming = src() return if current == incoming or incoming % 2 == 1 then current or incoming else incoming end) @@ -192,29 +192,10 @@ test("should trigger effect even computed already dirty", function() table.insert(calls, c2()) end) assert(table.concat(calls, ",") == "0foo", table.concat(calls, ",")) - assert(v() == 1, `expected 1 for v(), got {v()}`) + assert(v() == 1, v()) v(2) assert(table.concat(calls, ",") == "0foo,2foo", table.concat(calls, ",")) - assert(v() == 2, `expected 2 for v(), got {v()}`) -end) - -test("should handle inner effect clearing outer computed's deps on rerun", function() - local src, setSrc = signal(false) - local stopEffect: () -> () - local c = computed(function() - src() - effect(function() - return function() - stopEffect() - end - end) - effect(function() end) - return 0 - end) - stopEffect = effect(function() - c() - end) - setSrc(true) + assert(v() == 2, v()) end) return {} diff --git a/packages/charm/test/effect.test.luau b/packages/charm/test/effect.test.luau index 17cd0a0..9b526c4 100644 --- a/packages/charm/test/effect.test.luau +++ b/packages/charm/test/effect.test.luau @@ -239,11 +239,8 @@ test("should handle side effect with inner effects", function() table.clear(order) setB(1) setA(1) + assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`) end) - - -- DEVIATION: This assertion was moved outside of the effect so that it - -- runs after the batch updates have been processed - assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`) end) test("should handle flags are indirectly updated during checkDirty", function() @@ -271,48 +268,7 @@ test("should handle flags are indirectly updated during checkDirty", function() assert(triggers == 2, "did not update indirectly") end) -test("should not execute skipped effects from previous failed flush when updating unrelated signal", function() - local a, setA = signal(0) - local b, setB = signal(0) - local c, setC = signal(0) - local d = computed(function() - c() - return 0 - end) - - local effect3Executed = false - - effect(function() - a() - end) - effect(function() - if a() == 2 then - error("Error in effect 2") - end - end) - effect(function() - a() - d() - effect3Executed = true - end) - effect(function() - b() - end) - - setA(1) - - effect3Executed = false - local success, err = pcall(setA, 2) - assert(not success and string.find(tostring(err), "Error in effect 2"), `bad error: {err}`) - - assert(not effect3Executed, "effect 3 should not have executed after failed flush") - setB(1) - assert(not effect3Executed, "effect 3 should not have executed after updating unrelated signal b") - setC(1) - assert(effect3Executed, "effect 3 should have executed after updating signal c") -end) - -test("should allow finite recursion", function() +test("should allow recursion", function() local src, setSrc = signal(0) local order = {} @@ -408,186 +364,15 @@ test("should run cleanups outside any evaluation context", function() assert(calls == 0, "should not run after b update") end) -test("should not retrigger the effect if the cleanup modifies one of the dependencies", function() - local a, setA = signal(0) - local calls = {} +test("infinite recursion should eventually throw", function() + local counter, setCounter = signal(0) - effect(function() - table.insert(calls, a()) - return function() - setA(2) - end - end) - assert(#calls == 1, "should have run once initially") - table.clear(calls) - - setA(1) - assert(#calls == 1, "should run once after an update") - assert(calls[1] == 2, `bad call: {calls[1]}`) -end) - -test("should run the cleanup if the effect disposes itself", function() - local a, setA = signal(0) - local calls = 0 - local dispose: () -> () - - dispose = effect(function() - if a() > 0 then - dispose() - return function() - calls += 1 - end - end - end) - assert(calls == 0, "should not have called cleanup initially") - setA(1) - assert(calls == 1, "should have called cleanup once after first update") - setA(2) - assert(calls == 1, "should not call cleanup again after second update") -end) - -test("should not run the effect if the cleanup function disposes it", function() - local a, setA = signal(0) - local calls = 0 - local dispose: () -> () - - dispose = effect(function() - a() - calls += 1 - return function() - dispose() - end - end) - assert(calls == 1, "should have run effect once initially") - setA(1) - assert(calls == 1, "should not run effect again after update") -end) - -test("should reset the cleanup if the effect throws", function() - local a, setA = signal(0) - local calls = 0 - - effect(function() - if a() == 0 then - return function() - calls += 1 - end - else - error("hello") - end - end) - assert(calls == 0, "should not have called cleanup initially") - local success, err = pcall(setA, 1) - assert(not success and string.find(tostring(err), "hello"), `bad error: {err}`) - assert(calls == 1, "should have called cleanup once after throwing") - setA(0) - assert(calls == 1, "should not call cleanup again after resetting value") -end) - -test("recursive effects should run and cleanup in order", function() - local a, setA = signal(0) - local order: { string } = {} - - effect(function() - local value = a() - table.insert(order, `+{value}`) - if value < 3 then - setA(value + 1) - end - return function() - table.insert(order, `-{value}`) - end - end) - assert(table.concat(order) == "+0-0+1-1+2-2+3", `bad order: {table.concat(order)}`) - table.clear(order) - setA(100) - assert(table.concat(order) == "-3+100", `bad order: {table.concat(order)}`) -end) - -test("should effects dispose in order", function() - local order: { number } = {} - - local src, setSrc = signal(false) - local stopEffect = effect(function() - src() - for i = 1, 3 do - effect(function() - return function() - table.insert(order, i) - end - end) - end - end) - - assert(#order == 0, "should not cleanup immediately") - setSrc(true) - assert(table.concat(order) == "123", `bad rerun order: {table.concat(order)}`) - table.clear(order) - stopEffect() - assert(table.concat(order) == "123", `bad dispose order: {table.concat(order)}`) - table.clear(order) - setSrc(false) - assert(#order == 0, "should not cleanup after dispose") -end) - -test("should handle inner effect clearing outer effect's deps on rerun", function() - local src, setSrc = signal(false) - local stopEffect: () -> () - stopEffect = effect(function() - src() + local success = pcall(function() effect(function() - return function() - stopEffect() - end - end) - effect(function() end) - end) - setSrc(true) -end) - -test.skip("should handle inner effect cleanup disposing outer effect", function() - local order: { number } = {} - - local src, setSrc = signal(false) - local stopEffect: () -> () - stopEffect = effect(function() - src() - table.insert(order, 0) - for i = 1, 4 do - effect(function() - return function() - table.insert(order, i) - stopEffect() - end - end) - end - end) - assert(table.concat(order) == "0", "should not cleanup immediately") - - table.clear(order) - setSrc(true) - assert(table.concat(order) == "1234", `bad rerun order: {table.concat(order)}`) - - table.clear(order) - setSrc(false) - assert(#order == 0, "should not cleanup after dispose") -end) - -test.skip("should old inner effect dispose before outer effect re-runs", function() - local order: { string } = {} - local src, setSrc = signal(false) - effect(function() - src() - table.insert(order, "outer") - effect(function() - return function() - table.insert(order, "inner") - end + setCounter(counter() + 1) end) end) - table.clear(order) - setSrc(true) - assert(table.concat(order) == "innerouter", `bad rerun order: {table.concat(order)}`) + assert(not success, "should have thrown") end) return {} diff --git a/packages/charm/test/effectScope.test.luau b/packages/charm/test/effectScope.test.luau index 964b7d5..932ebc1 100644 --- a/packages/charm/test/effectScope.test.luau +++ b/packages/charm/test/effectScope.test.luau @@ -31,8 +31,8 @@ end) test("should run indirect scope cleanup", function() local cleanups = 0 - local cleanup: () -> () + local cleanup local stopScope = effectScope(function() cleanup = effectScope(function() onCleanup(function() @@ -50,8 +50,8 @@ end) test("should run indirect effect cleanup", function() local cleanups = 0 - local a, setA = signal(false) + local a, setA = signal(false) local stopScope = effectScope(function() effect(function() a() @@ -70,26 +70,13 @@ test("should run indirect effect cleanup", function() assert(cleanups == 2, "should not cleanup after update") end) -test("should dispose inner effects if created in an effect", function() - local source, setSource = signal(1) - local triggers = 0 - local dispose: () -> () - - effect(function() - dispose = effectScope(function() - effect(function() - source() - triggers += 1 - end) +test("should disallow yielding", function() + local success = pcall(function() + effectScope(function() + (if task then task.wait else coroutine.yield :: never)() end) - assert(triggers == 1, "did not run initially") end) - - setSource(2) - assert(triggers == 2, "did not run after update") - dispose() - setSource(3) - assert(triggers == 2, "ran after dispose") + assert(not success, "should error on yield") end) return {} diff --git a/packages/charm/test/errors.test.luau b/packages/charm/test/errors.test.luau deleted file mode 100644 index 151a0d5..0000000 --- a/packages/charm/test/errors.test.luau +++ /dev/null @@ -1,200 +0,0 @@ ---!optimize 1 - -local Charm = require("../src") -local test = require("../../../tests/test") - -test("should disallow yielding", function() - local success = pcall(function() - Charm.effectScope(function() - coroutine.yield() - end) - end) - assert(not success, "should error on yield") -end) - -test.describe("traceback", function() - local DEFAULT_ERROR = "Test error" - - local function fail(message: string?) - error(message or DEFAULT_ERROR, 0) - end - - local function indirectError(message: string?) - fail(message) - end - - local function assertStackTrace(err: unknown, expectation: string?) - expectation = expectation or DEFAULT_ERROR - assert(type(err) == "string", "Expected error to be a string") - assert(string.find(err, expectation), `Expected error message to be "{expectation}", got: {err}`) - assert(string.find(err, "fail"), `Expected stack trace to contain fail, got: {err}`) - assert(string.find(err, "indirectError"), `Expected stack trace to contain indirectError, got: {err}`) - end - - test("signal update", function() - local _, err = pcall(function() - local _, setSrc = Charm.signal(0) - setSrc(function() - indirectError() - return 1 - end) - end) - assertStackTrace(err) - end) - - test("atom update", function() - local _, err = pcall(function() - local src = Charm.atom(0) - src(function() - indirectError() - return 1 - end) - end) - assertStackTrace(err) - end) - - test("computed", function() - local _, err = pcall(function() - local src = Charm.computed(function() - indirectError() - end) - src() - end) - assertStackTrace(err) - end) - - test("effect init", function() - local _, err = pcall(function() - Charm.effect(function() - indirectError() - end) - end) - assertStackTrace(err) - end) - - test("effect run", function() - local _, err = pcall(function() - local src, setSrc = Charm.signal(0) - Charm.effect(function() - if src() == 1 then - indirectError() - end - end) - setSrc(1) - end) - assertStackTrace(err) - end) - - test("effect cleanups", function() - local _, err = pcall(function() - local dispose = Charm.effect(function() - Charm.onCleanup(function() - indirectError("onCleanup") - end) - return function() - indirectError("returned cleanup") - end - end) - dispose() - end) - assertStackTrace(err, "onCleanup") - assertStackTrace(err, "returned cleanup") - end) - - test("trigger", function() - local _, err = pcall(function() - Charm.trigger(function() - indirectError() - end) - end) - assertStackTrace(err) - end) - - test("untracked", function() - local _, err = pcall(function() - Charm.effectScope(function() - Charm.untracked(function() - indirectError() - end) - end) - end) - assertStackTrace(err) - end) - - test("batch", function() - local _, err = pcall(function() - Charm.batch(function() - indirectError() - end) - end) - assertStackTrace(err) - end) - - test("subscribe getter", function() - local _, err = pcall(function() - Charm.subscribe(function() - indirectError() - end, function() end) - end) - assertStackTrace(err) - end) - - test("subscribe callback", function() - local _, err = pcall(function() - local src, setSrc = Charm.signal(0) - Charm.subscribe(src, function() - indirectError() - end) - setSrc(1) - end) - assertStackTrace(err) - end) - - test("listen getter", function() - local _, err = pcall(function() - Charm.listen(function() - indirectError() - end, function() end) - end) - assertStackTrace(err) - end) - - test("listen callback", function() - local _, err = pcall(function() - local src, setSrc = Charm.signal(0) - Charm.listen(src, function() - indirectError() - end) - setSrc(1) - end) - assertStackTrace(err) - end) - - test("mapped getter", function() - local _, err = pcall(function() - local src = Charm.mapped(function() - indirectError() - return { 1 } - end, function() - return 1 - end) - src() - end) - assertStackTrace(err) - end) - - test("mapped transform", function() - local _, err = pcall(function() - local src = Charm.mapped(function() - return { 1 } - end, function() - indirectError() - return 1 - end) - src() - end) - assertStackTrace(err) - end) -end) - -return {} diff --git a/packages/charm/test/gc.test.luau b/packages/charm/test/gc.test.luau index aed0af1..2d53389 100644 --- a/packages/charm/test/gc.test.luau +++ b/packages/charm/test/gc.test.luau @@ -26,7 +26,7 @@ end immediately closing it, causing alien-signals to unlink the computed value from its dependencies and allow it to be garbage collected. ]] -local function unlink(getter: () -> unknown) +local function unlink(getter: () -> ()) effectScope(function() getter() end)() diff --git a/packages/charm/test/init.luau b/packages/charm/test/init.luau index 52da435..e13fab7 100644 --- a/packages/charm/test/init.luau +++ b/packages/charm/test/init.luau @@ -12,10 +12,6 @@ test.describe("effectScope", function() require("@self/effectScope.test") end) -test.describe("error", function() - require("@self/errors.test") -end) - test.describe("gc", function() require("@self/gc.test") end) diff --git a/packages/charm/test/mapped.test.luau b/packages/charm/test/mapped.test.luau index 33620d4..6792469 100644 --- a/packages/charm/test/mapped.test.luau +++ b/packages/charm/test/mapped.test.luau @@ -104,25 +104,4 @@ test("should map keys", function() assert(state.D == 4, "should add d") end) -test("should remove old key if mapper returns a new key", function() - local src, setSrc = signal({ 1 }) - local map = mapped(src, function(value: number, index: number) - return true, `{index}={value}` - end) - - local state = map() - assert(state["1=1"], "should have mapped first element to key 1=1") - - setSrc({ 2 }) - state = map() - assert(state["1=1"] == nil, "should have removed old key 1=1") - assert(state["1=2"], "should have remapped element to key 1=2") - - setSrc({ 3, 1 }) - state = map() - assert(state["1=2"] == nil, "should have removed old key 1=2") - assert(state["1=3"], "should have remapped element to key 1=3") - assert(state["2=1"], "should have mapped second element to key 2=1") -end) - return {} diff --git a/packages/charm/test/observe.test.luau b/packages/charm/test/observe.test.luau index 64bbd7d..86efdc3 100644 --- a/packages/charm/test/observe.test.luau +++ b/packages/charm/test/observe.test.luau @@ -12,7 +12,7 @@ test("should observe items", function() local updates = {} local cleanups = {} - observe(items, function(_, key: number) + observe(items, function(_, key) local get = computed(function() return items()[key] end) @@ -91,11 +91,11 @@ end) test("should handle recursive additions and removals", function() local items, setItems = signal({ 1 }) - local edits: { string } = {} + local edits = {} observe(items, function(item) if item < 5 then - setItems(function(state: { number }) + setItems(function(state) state = table.clone(state) table.insert(state, item + 1) return state @@ -114,10 +114,10 @@ end) test("should handle self removal", function() local items, setItems = signal({ 1, 2, 3, 4 }) - local edits: { string } = {} + local edits = {} observe(items, function(item, index) - setItems(function(state: { number }) + setItems(function(state) state = table.clone(state) state[index] = nil return state @@ -134,7 +134,7 @@ end) test("should allow disposal during addition", function() local items, setItems = signal({}) local edits = {} - local dispose: () -> () + local dispose dispose = observe(items, function(item) if item == 1 then @@ -153,7 +153,7 @@ end) test("should allow disposal during removal", function() local items, setItems = signal({ 1, 2, 3, 4 }) local edits = {} - local dispose: () -> () + local dispose dispose = observe(items, function(item) table.insert(edits, `+{item}`) @@ -167,32 +167,4 @@ test("should allow disposal during removal", function() assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`) end) -test("should not hang on error after recursive update", function() - local items, setItems = signal({}) - - observe(items, function() - setItems({ {} }) - error("test error") - end) - - local success, err = pcall(function() - setItems({ {} }) - end) - assert(not success, "should error") - assert(string.find(tostring(err), "test error"), `bad error: {err}`) -end) - -test("should not hang on error after recursive init", function() - local items, setItems = signal({ {} }) - - local success, err = pcall(function() - observe(items, function() - setItems({ {} }) - error("test error") - end) - end) - assert(not success, "should error") - assert(string.find(tostring(err), "test error"), `bad error: {err}`) -end) - return {} diff --git a/packages/charm/test/signal.test.luau b/packages/charm/test/signal.test.luau index 21c1746..ebc8e51 100644 --- a/packages/charm/test/signal.test.luau +++ b/packages/charm/test/signal.test.luau @@ -6,7 +6,7 @@ local effect = Charm.effect test("should accept equality function", function() local calls = {} - local src, setSrc = signal(0, function(current: number, incoming: number) + local src, setSrc = signal(0, function(current, incoming) return current == incoming or incoming % 2 == 1 end) @@ -46,22 +46,4 @@ test("should not freeze values with metatables", function() ) end) -test("should setter return same value as getter even if equal", function() - local src, setSrc = signal(0, function() - return true - end) - assert(setSrc(1) == src(), `setSrc(1) expected 0, got {setSrc(1)}`) -end) - -test("should setter return latest value", function() - local src, setSrc = signal(0) - effect(function() - if src() == 1 then - setSrc(2) - end - end) - assert(setSrc(1) == 2, `setSrc(1) expected 2, got {setSrc(1)}`) - assert(src() == 2, `src() expected 2, got {src()}`) -end) - return {} diff --git a/packages/charm/test/subscribe.test.luau b/packages/charm/test/subscribe.test.luau index 10ab932..6936c7a 100644 --- a/packages/charm/test/subscribe.test.luau +++ b/packages/charm/test/subscribe.test.luau @@ -46,7 +46,7 @@ test("should run in untracked", function() end) test("should ensure correct execution order", function() - local order: { number } = {} + local order = {} local n1, setN1 = signal(0) local n2, setN2 = signal(0) local sum = computed(function() @@ -72,7 +72,7 @@ test("should ensure correct execution order", function() end) test("should skip redundant updates", function() - local order: { "even" | "odd" } = {} + local order = {} local a, setA = signal(0) subscribe(function(): "even" | "odd" diff --git a/packages/charm/test/topology.test.luau b/packages/charm/test/topology.test.luau index 3675b3d..06ee3c2 100644 --- a/packages/charm/test/topology.test.luau +++ b/packages/charm/test/topology.test.luau @@ -442,16 +442,18 @@ end) test.describe("error handling", function() test("should keep graph consistent on errors during activation", function() local a = atom(0) - local b = computed(function(): nil + local b = computed(function() error("fail") end) local c = computed(function() return a() end) - local success, result = pcall(b) + local success, result = pcall(function() + b() + end) assert(not success, "should throw") - assert(string.match(tostring(result), "fail"), "should throw fail") + assert(string.match(result, "fail"), "should throw fail") a(1) assert(c() == 1, "c should recover and return 1") @@ -459,7 +461,7 @@ test.describe("error handling", function() test("should keep graph consistent on errors in computeds", function() local a = atom(0) - local b = computed(function(): number + local b = computed(function() if a() == 1 then error("fail") end @@ -472,9 +474,11 @@ test.describe("error handling", function() assert(c() == 0, "c should be 0") a(1) - local success, result = pcall(b) + local success, result = pcall(function() + b() + end) assert(not success, "should throw") - assert(string.match(tostring(result), "fail"), "should throw fail") + assert(string.match(result, "fail"), "should throw fail") a(2) assert(c() == 2, "c should recover and return 2") diff --git a/packages/charm/test/trigger.test.luau b/packages/charm/test/trigger.test.luau index 20ebf32..c548a86 100644 --- a/packages/charm/test/trigger.test.luau +++ b/packages/charm/test/trigger.test.luau @@ -3,7 +3,6 @@ local test = require("../../../tests/test") local computed = Charm.computed local effect = Charm.effect -local listen = Charm.listen local signal = Charm.signal local subscribe = Charm.subscribe local trigger = Charm.trigger @@ -68,7 +67,7 @@ test("should trigger effect once", function() end) test("should not notify the trigger function sub", function() - local src1 = signal({} :: { number }) + local src1 = signal({}) local src2 = computed(function() return src1() end) @@ -91,21 +90,9 @@ test("should trigger updates for subscribe", function() triggers += 1 end) + table.insert(src(), 1) trigger(src) assert(triggers == 1, "subscribe callback should run after trigger") end) -test("should trigger updates for listen", function() - local src = signal({} :: { number }) - local triggers = 0 - - listen(src, function() - triggers += 1 - end) - assert(triggers == 1, "listen callback should run once initially") - - trigger(src) - assert(triggers == 2, "listen callback should run after trigger") -end) - return {} diff --git a/packages/charm/test/untracked.test.luau b/packages/charm/test/untracked.test.luau index 3c7fea4..cd75c63 100644 --- a/packages/charm/test/untracked.test.luau +++ b/packages/charm/test/untracked.test.luau @@ -38,7 +38,7 @@ end) test("should run detached from effect scope", function() local effects = 0 - local stopEffect: () -> () + local stopEffect local stopScope = effectScope(function() untracked(function() diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index 25eb0a5..4c76e65 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.11.1" +version = "0.11.0-rc.5" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] diff --git a/packages/deep-charm/package.json b/packages/deep-charm/package.json index 081b711..96d06af 100644 --- a/packages/deep-charm/package.json +++ b/packages/deep-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/deep-charm", - "version": "0.1.1", + "version": "0.1.0-rc.1", "description": "Use Charm with plain Luau tables", "license": "MIT", "main": "src/init.luau", diff --git a/packages/deep-charm/src/init.luau b/packages/deep-charm/src/init.luau index 9e16cb1..ef9ab05 100644 --- a/packages/deep-charm/src/init.luau +++ b/packages/deep-charm/src/init.luau @@ -36,9 +36,9 @@ end ]] local function toRaw(value: T): T if isReactive(value) then - local proxy = value :: ReactiveProxy - proxy.__track() - return toRaw(proxy.__target) + local value = value :: ReactiveProxy & T + value.__track() + return toRaw(value.__target) end return value end @@ -115,7 +115,7 @@ end @return A reactive proxy if the value is a table ]] local function toReactive(value: T, trackParent: (() -> ())?): T - return if type(value) == "table" then reactive(value, trackParent) :: T else value :: T + return if type(value) == "table" then reactive(value, trackParent) else value end function ReactiveProxy.__index(self: ReactiveProxy, key: any): any diff --git a/packages/deep-charm/test/reactive.test.luau b/packages/deep-charm/test/reactive.test.luau index 41654b1..4b747c9 100644 --- a/packages/deep-charm/test/reactive.test.luau +++ b/packages/deep-charm/test/reactive.test.luau @@ -2,10 +2,7 @@ local Charm = require("../Charm") local DeepCharm = require("../src") local test = require("../../../tests/test") -local batch = Charm.batch -local computed = Charm.computed local effect = Charm.effect -local observe = Charm.observe local reactive = DeepCharm.reactive local toRaw = DeepCharm.toRaw @@ -52,10 +49,10 @@ test("should unwrap proxy assignments", function() end) src.a, src.b = src.a, src.b - assert(runs == 1, `bad runs: {runs}`) + assert(runs == 1, runs) src.a, src.b = src.b, src.a - assert(runs == 3, `bad runs: {runs}`) + assert(runs == 3, runs) end) test("should track iteration", function() @@ -128,10 +125,10 @@ test("should not track unread properties", function() local _ = src.value.a calls += 1 end) - assert(calls == 1, `bad calls: {calls}`) + assert(calls == 1, calls) src.value.b = 3 - assert(calls == 1, `bad calls: {calls}`) + assert(calls == 1, calls) end) test("should overwrite with table passed to setter", function() @@ -162,10 +159,10 @@ test("should unwrap proxy", function() assert(toRaw(src) == raw, "unwrap did not return the original table") assert(toRaw(raw) == raw, "unwrap modified a non-proxy value") - assert(runs == 1, `bad runs: {runs}`) + assert(runs == 1, runs) src.x = 1 - assert(runs == 2, `bad runs: {runs}`) + assert(runs == 2, runs) end) test("should unwrap nested proxy", function() @@ -180,53 +177,13 @@ test("should unwrap nested proxy", function() end) assert(toRaw(src) == raw, "unwrap did not return the original table") - assert(runs == 1, `bad runs: {runs}`) + assert(runs == 1, runs) inner.x = 1 - assert(runs == 2, `bad runs: {runs}`) + assert(runs == 2, runs) src.y = 2 - assert(runs == 3, `bad runs: {runs}`) -end) - -test("should observe items", function() - local items, updateItems = reactive({ 0, 0, 0 }) - local updates = {} - local cleanups = {} - - observe(function() - return toRaw(items) - end, function(_, key: number) - local get = computed(function() - return items[key] - end) - - effect(function() - table.insert(updates, get()) - end) - - return function() - table.insert(cleanups, key) - end - end) - - assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`) - - table.clear(updates) - updateItems(function(state) - state[1] = 1 - state[2] = 2 - state[3] = 3 - end) - assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`) - - table.clear(updates) - batch(function() - items[3] = nil - items[4] = 4 - end) - assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`) - assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`) + assert(runs == 3, runs) end) return {} diff --git a/packages/deep-charm/wally.toml b/packages/deep-charm/wally.toml index 3f2702f..bbd35f0 100644 --- a/packages/deep-charm/wally.toml +++ b/packages/deep-charm/wally.toml @@ -1,9 +1,9 @@ [package] name = "littensy/deep-charm" -version = "0.1.1" +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" +Charm = "littensy/charm@^0.11.0-rc.5" diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index ca08eca..4976c98 100644 --- a/packages/react-charm/package.json +++ b/packages/react-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.4.1", + "version": "0.4.0-rc.3", "description": "React bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 08b5342..205514a 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -13,12 +13,12 @@ local React = require("./React") @param dependencies An array of values used to memoize the getter. @return The current state. ]] -local function useSignalState(getter: () -> T, dependencies: {}?): T - local value: T, setValue = React.useState(getter) +local function useSignalState(getter: () -> T, dependencies: { any }?): T + local value, setValue = React.useState(getter) - React.useLayoutEffect(function(): () -> () - return Charm.listen(getter, setValue :: (T, T?) -> ()) - end, dependencies :: { any } or {}) + React.useLayoutEffect(function() + return Charm.listen(getter, setValue) + end, dependencies or {}) return value end @@ -34,13 +34,13 @@ end @param dependencies An array of values used to memoize the getter. @return A binding that stores the current state. ]] -local function useSignalBinding(getter: () -> T, dependencies: {}?) +local function useSignalBinding(getter: () -> T, dependencies: { any }?) local initialValue = React.useMemo(getter, {}) local binding, setBinding = React.useBinding(initialValue) - React.useLayoutEffect(function(): () -> () - return Charm.listen(getter, setBinding :: (T, T?) -> ()) - end, dependencies :: { any } or {}) + React.useLayoutEffect(function() + return Charm.listen(getter, setBinding) + end, dependencies or {}) return binding end diff --git a/packages/react-charm/test/helpers/renderHook.luau b/packages/react-charm/test/helpers/renderHook.luau index 661d2e0..a059fb2 100644 --- a/packages/react-charm/test/helpers/renderHook.luau +++ b/packages/react-charm/test/helpers/renderHook.luau @@ -13,13 +13,12 @@ local function renderHook(callback: (Props) -> Result, props: Pro local function Component(props: Props) hook.result = callback(props) - return nil end function hook.rerender(newProps: Props?) props = newProps or props ReactRoblox.act(function() - root:render(React.createElement(Component, props) :: any) + root:render(React.createElement(Component, props)) end) end diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index 51ee9ab..12dd094 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/react-charm" -version = "0.4.1" +version = "0.4.0-rc.3" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0" +Charm = "littensy/charm@^0.11.0-rc.5" React = "jsdotlua/react@^17" diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json index 9b64809..6a7ed72 100644 --- a/packages/vide-charm/package.json +++ b/packages/vide-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.4.1", + "version": "0.4.0-rc.3", "description": "Vide bindings for Charm state management", "license": "MIT", "main": "src/init.luau", diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index 5a27f54..db9d2e7 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -11,7 +11,7 @@ local Vide = require("./Vide") local function useSignalState(getter: () -> State): () -> State local state = Vide.source(getter()) - Vide.cleanup(Charm.listen(getter, state :: (State, State?) -> ())) + Vide.cleanup(Charm.listen(getter, state)) return state end diff --git a/packages/vide-charm/test/useSignalState.test.luau b/packages/vide-charm/test/useSignalState.test.luau index 9661501..3bfc74f 100644 --- a/packages/vide-charm/test/useSignalState.test.luau +++ b/packages/vide-charm/test/useSignalState.test.luau @@ -5,7 +5,7 @@ local test = require("../../../tests/test") test("tracks signal state", function() local getState, setState = Charm.signal(0) - local source: () -> number + local source local cleanup = Vide.root(function() source = VideCharm.useSignalState(getState) end) @@ -17,7 +17,7 @@ end) test("accepts a selector", function() local getState, setState = Charm.signal(0) - local source: () -> number + local source local cleanup = Vide.root(function() source = VideCharm.useSignalState(function() return 2 * getState() diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index 4aa1f71..8a15227 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/vide-charm" -version = "0.4.1" +version = "0.4.0-rc.4" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0" +Charm = "littensy/charm@^0.11.0-rc.5" Vide = "centau/vide@^0.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5c8ddc..066affe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,186 +1,3 @@ ---- -lockfileVersion: '9.0' - -importers: - - .: - configDependencies: {} - packageManagerDependencies: - '@pnpm/exe': - specifier: 12.8.1 - version: 12.8.1 - pnpm: - specifier: 12.8.1 - version: 12.8.1 - -packages: - - '@pnpm/exe.android-arm64@12.8.1': - resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} - cpu: [arm64] - os: [android] - - '@pnpm/exe.android-x64@12.8.1': - resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} - cpu: [x64] - os: [android] - - '@pnpm/exe.darwin-arm64@12.8.1': - resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} - cpu: [arm64] - os: [darwin] - - '@pnpm/exe.darwin-x64@12.8.1': - resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} - cpu: [x64] - os: [darwin] - - '@pnpm/exe.freebsd-x64@12.8.1': - resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} - cpu: [x64] - os: [freebsd] - - '@pnpm/exe.linux-arm64-musl@12.8.1': - resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@pnpm/exe.linux-arm64@12.8.1': - resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-ppc64@12.8.1': - resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-riscv64@12.8.1': - resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-s390x@12.8.1': - resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-x64-musl@12.8.1': - resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@pnpm/exe.linux-x64@12.8.1': - resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.win32-arm64@12.8.1': - resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} - cpu: [arm64] - os: [win32] - - '@pnpm/exe.win32-x64@12.8.1': - resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} - cpu: [x64] - os: [win32] - - '@pnpm/exe@12.8.1': - resolution: {integrity: sha512-ju5MeefhEEXI7NO3u6oj54uGBOkN0pVhI+tcmuZDkTTqVdRZvEVEo+zhjwAoe8vEXguQCvsa6cTDhmFPxpOXeQ==} - engines: {node: '>=18.*'} - hasBin: true - - pnpm@12.8.1: - resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} - engines: {node: '>=18.*'} - hasBin: true - -snapshots: - - '@pnpm/exe.android-arm64@12.8.1': - optional: true - - '@pnpm/exe.android-x64@12.8.1': - optional: true - - '@pnpm/exe.darwin-arm64@12.8.1': - optional: true - - '@pnpm/exe.darwin-x64@12.8.1': - optional: true - - '@pnpm/exe.freebsd-x64@12.8.1': - optional: true - - '@pnpm/exe.linux-arm64-musl@12.8.1': - optional: true - - '@pnpm/exe.linux-arm64@12.8.1': - optional: true - - '@pnpm/exe.linux-ppc64@12.8.1': - optional: true - - '@pnpm/exe.linux-riscv64@12.8.1': - optional: true - - '@pnpm/exe.linux-s390x@12.8.1': - optional: true - - '@pnpm/exe.linux-x64-musl@12.8.1': - optional: true - - '@pnpm/exe.linux-x64@12.8.1': - optional: true - - '@pnpm/exe.win32-arm64@12.8.1': - optional: true - - '@pnpm/exe.win32-x64@12.8.1': - optional: true - - '@pnpm/exe@12.8.1': - optionalDependencies: - '@pnpm/exe.android-arm64': 12.8.1 - '@pnpm/exe.android-x64': 12.8.1 - '@pnpm/exe.darwin-arm64': 12.8.1 - '@pnpm/exe.darwin-x64': 12.8.1 - '@pnpm/exe.freebsd-x64': 12.8.1 - '@pnpm/exe.linux-arm64': 12.8.1 - '@pnpm/exe.linux-arm64-musl': 12.8.1 - '@pnpm/exe.linux-ppc64': 12.8.1 - '@pnpm/exe.linux-riscv64': 12.8.1 - '@pnpm/exe.linux-s390x': 12.8.1 - '@pnpm/exe.linux-x64': 12.8.1 - '@pnpm/exe.linux-x64-musl': 12.8.1 - '@pnpm/exe.win32-arm64': 12.8.1 - '@pnpm/exe.win32-x64': 12.8.1 - - pnpm@12.8.1: - optionalDependencies: - '@pnpm/exe.android-arm64': 12.8.1 - '@pnpm/exe.android-x64': 12.8.1 - '@pnpm/exe.darwin-arm64': 12.8.1 - '@pnpm/exe.darwin-x64': 12.8.1 - '@pnpm/exe.freebsd-x64': 12.8.1 - '@pnpm/exe.linux-arm64': 12.8.1 - '@pnpm/exe.linux-arm64-musl': 12.8.1 - '@pnpm/exe.linux-ppc64': 12.8.1 - '@pnpm/exe.linux-riscv64': 12.8.1 - '@pnpm/exe.linux-s390x': 12.8.1 - '@pnpm/exe.linux-x64': 12.8.1 - '@pnpm/exe.linux-x64-musl': 12.8.1 - '@pnpm/exe.win32-arm64': 12.8.1 - '@pnpm/exe.win32-x64': 12.8.1 - ---- lockfileVersion: '9.0' settings: @@ -192,8 +9,8 @@ importers: .: devDependencies: '@eslint/js': - specifier: ^9.39.4 - version: 9.39.4 + specifier: ^9.39.2 + version: 9.39.2 '@rbxts/compiler-types': specifier: 3.0.0-types.0 version: 3.0.0-types.0 @@ -204,29 +21,29 @@ importers: specifier: 17.3.7-ts.1 version: 17.3.7-ts.1(@rbxts/react@17.3.7-ts.1) '@rbxts/types': - specifier: ^1.0.913 - version: 1.0.913 + specifier: ^1.0.908 + version: 1.0.908 '@rbxts/vide': - specifier: ^0.6.1 - version: 0.6.1 + specifier: ^0.6.0 + version: 0.6.0 '@typescript-eslint/eslint-plugin': - specifier: ^8.58.0 - version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + specifier: ^8.56.0 + version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^8.58.0 - version: 8.58.0(eslint@9.39.4)(typescript@5.9.3) + specifier: ^8.56.0 + version: 8.56.0(eslint@9.39.2)(typescript@5.9.3) eslint: - specifier: ^9.39.4 - version: 9.39.4 + specifier: ^9.39.2 + version: 9.39.2 eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4) + version: 10.1.8(eslint@9.39.2) eslint-plugin-prettier: specifier: ^5.5.5 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.1) + version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2))(eslint@9.39.2)(prettier@3.8.1) eslint-plugin-roblox-ts: - specifier: ^1.4.0 - version: 1.4.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + specifier: ^1.3.1 + version: 1.3.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) prettier: specifier: ^3.8.1 version: 3.8.1 @@ -237,8 +54,8 @@ importers: specifier: ^5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.58.0 - version: 8.58.0(eslint@9.39.4)(typescript@5.9.3) + specifier: ^8.56.0 + version: 8.56.0(eslint@9.39.2)(typescript@5.9.3) packages/charm: {} @@ -284,8 +101,8 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.2': - resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/config-helpers@0.4.2': @@ -296,12 +113,12 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + '@eslint/eslintrc@3.3.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + '@eslint/js@9.39.2': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -328,6 +145,18 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -406,15 +235,12 @@ packages: '@rbxts/react@17.3.7-ts.1': resolution: {integrity: sha512-/jqZ2TvyLSAPwC30h95loUmy4XN2PwPhv6fBARJKtZfyozFx2RuxC9FiQSjpEZ2uOOPzr60It0MBUf94D+tsMg==} - '@rbxts/types@1.0.913': - resolution: {integrity: sha512-AweuNYRVJb6y4PNajx2mnpqv+hm0+8GWtH9Dj4EMUuOVN5qcfcOMNSqjvQ4dmF5u0yQ89HKGeXa+4sLnaTIEng==} + '@rbxts/types@1.0.908': + resolution: {integrity: sha512-b2nVXH4g/roLmOxVO3WYV2BdGaHnmX8BiGECwhuCmIClA3Do6t123/vhGmytiVhctHGUXD6VWMC1tX5rPlTGgQ==} '@rbxts/vide@0.6.0': resolution: {integrity: sha512-OLfs4kqV2IP79xS+gZAprMujuYEc+n0JwMhJF+puNVoxBXSzOv2kKlgeLJhscCy7Jdhr9TbqsaRNLJP5akbfog==} - '@rbxts/vide@0.6.1': - resolution: {integrity: sha512-syFqx+HYXy0TauDCGLhc68K2cAlfgYookIJSRtJZ01W7mUI+EKyj80x2L91dQxos3BwAAGJqBD/ovWU4M//+Lg==} - '@roblox-ts/luau-ast@2.0.0': resolution: {integrity: sha512-cmMi093IdwBOLVxwuordhM8AmtbyTIyRpsTbB0D/JauidW4SXsQRQowSwWjHo4QP0DRJBXvOIlxtqEQi50uNzQ==} @@ -433,20 +259,26 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@typescript-eslint/eslint-plugin@8.58.0': - resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} + '@typescript-eslint/eslint-plugin@8.56.0': + resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.58.0 + '@typescript-eslint/parser': ^8.56.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.58.0': - resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} + '@typescript-eslint/parser@8.56.0': + resolution: {integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.46.4': + resolution: {integrity: sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' '@typescript-eslint/project-service@8.56.0': resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} @@ -454,19 +286,19 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.58.0': - resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} + '@typescript-eslint/scope-manager@8.46.4': + resolution: {integrity: sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/scope-manager@8.56.0': resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.58.0': - resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} + '@typescript-eslint/tsconfig-utils@8.46.4': + resolution: {integrity: sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' '@typescript-eslint/tsconfig-utils@8.56.0': resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} @@ -474,11 +306,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/tsconfig-utils@8.58.0': - resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} + '@typescript-eslint/type-utils@8.46.4': + resolution: {integrity: sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' '@typescript-eslint/type-utils@8.56.0': resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} @@ -487,20 +320,19 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.58.0': - resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} + '@typescript-eslint/types@8.46.4': + resolution: {integrity: sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/types@8.56.0': resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.58.0': - resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} + '@typescript-eslint/typescript-estree@8.46.4': + resolution: {integrity: sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' '@typescript-eslint/typescript-estree@8.56.0': resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} @@ -508,11 +340,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/typescript-estree@8.58.0': - resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} + '@typescript-eslint/utils@8.46.4': + resolution: {integrity: sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' '@typescript-eslint/utils@8.56.0': resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} @@ -521,21 +354,14 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.58.0': - resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} + '@typescript-eslint/visitor-keys@8.46.4': + resolution: {integrity: sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/visitor-keys@8.56.0': resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.58.0': - resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -546,8 +372,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -570,23 +396,15 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - brace-expansion@2.0.3: - resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} - - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} - engines: {node: 18 || 20 || >=22} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -665,8 +483,8 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-roblox-ts@1.4.0: - resolution: {integrity: sha512-OWj+JfQlvGSiAJ5YOmiQc+M29ZJFc7VFYP0U1J3orZNT9GP5CWfpRNqPHuDhsvz+ni0BwoI4aqGv2bIPDueGrQ==} + eslint-plugin-roblox-ts@1.3.1: + resolution: {integrity: sha512-+cXNQUlZF3sg/nS55DciRZeFiKpexGMrqXcO56mHf6TBpYEbfK+v/VboBPi8ZdaFyN5LgspnTh+t/Xi1AIvFJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=22.5.1} peerDependencies: '@typescript-eslint/parser': '>=6.0.0' @@ -685,12 +503,12 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + eslint-visitor-keys@5.0.0: + resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + eslint@9.39.2: + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -725,6 +543,10 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -734,6 +556,9 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -759,11 +584,11 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - fs-extra@11.3.4: - resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} + fs-extra@11.3.3: + resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} engines: {node: '>=14.14'} fsevents@2.3.3: @@ -881,15 +706,19 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} - minimatch@9.0.9: - resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} ms@2.1.3: @@ -929,12 +758,12 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} prelude-ls@1.2.1: @@ -954,6 +783,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -975,10 +807,17 @@ packages: engines: {node: '>= 0.4'} hasBin: true + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + roblox-ts@3.0.0: resolution: {integrity: sha512-hwAC2frIFlLJOtHd6F+5opMEhBgfAMK9z5l1mP+bykLBbMO5cn1q5lIwhhXbeh9Pq07rlhF8uGHlmeRLPd/3AA==} hasBin: true + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -1024,8 +863,8 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -1034,12 +873,12 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.58.0: - resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} + typescript-eslint@8.56.0: + resolution: {integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' typescript@5.5.3: resolution: {integrity: sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==} @@ -1089,18 +928,18 @@ packages: snapshots: - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2)': dependencies: - eslint: 9.39.4 + eslint: 9.39.2 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.21.1': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.5 + minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -1112,21 +951,21 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5': + '@eslint/eslintrc@3.3.3': dependencies: - ajv: 6.14.0 + ajv: 6.12.6 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.5 + minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.39.4': {} + '@eslint/js@9.39.2': {} '@eslint/object-schema@2.1.7': {} @@ -1146,6 +985,18 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + '@pkgr/core@0.2.9': {} '@rbxts-js/boolean@1.2.3-ts.1': @@ -1256,12 +1107,10 @@ snapshots: dependencies: '@rbxts-js/react': 17.3.7-ts.1 - '@rbxts/types@1.0.913': {} + '@rbxts/types@1.0.908': {} '@rbxts/vide@0.6.0': {} - '@rbxts/vide@0.6.1': {} - '@roblox-ts/luau-ast@2.0.0': {} '@roblox-ts/luau-ast@2.0.1': {} @@ -1269,41 +1118,50 @@ snapshots: '@roblox-ts/path-translator@1.1.0': dependencies: ajv: 8.18.0 - fs-extra: 11.3.4 + fs-extra: 11.3.3 '@roblox-ts/rojo-resolver@1.1.0': dependencies: ajv: 8.18.0 - fs-extra: 11.3.4 + fs-extra: 11.3.3 '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} - '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.0 - eslint: 9.39.4 + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 + eslint: 9.39.2 ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 + debug: 4.4.3 + eslint: 9.39.2 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.46.4(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.46.4(typescript@5.9.3) + '@typescript-eslint/types': 8.46.4 debug: 4.4.3 - eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -1317,60 +1175,67 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': + '@typescript-eslint/scope-manager@8.46.4': dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) - '@typescript-eslint/types': 8.58.0 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/visitor-keys': 8.46.4 '@typescript-eslint/scope-manager@8.56.0': dependencies: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/visitor-keys': 8.56.0 - '@typescript-eslint/scope-manager@8.58.0': + '@typescript-eslint/tsconfig-utils@8.46.4(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/visitor-keys': 8.58.0 + typescript: 5.9.3 '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.46.4(eslint@9.39.2)(typescript@5.9.3)': dependencies: + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.4(eslint@9.39.2)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.2 + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - '@typescript-eslint/type-utils@8.56.0(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.0(eslint@9.39.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2)(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.4 - ts-api-utils: 2.5.0(typescript@5.9.3) + eslint: 9.39.2 + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.4 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color + '@typescript-eslint/types@8.46.4': {} '@typescript-eslint/types@8.56.0': {} - '@typescript-eslint/types@8.58.0': {} + '@typescript-eslint/typescript-estree@8.46.4(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.46.4(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.46.4(typescript@5.9.3) + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/visitor-keys': 8.46.4 + debug: 4.4.3 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.4 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': dependencies: @@ -1379,60 +1244,45 @@ snapshots: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 - minimatch: 9.0.9 + minimatch: 9.0.5 semver: 7.7.4 tinyglobby: 0.2.15 - ts-api-utils: 2.5.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': + '@typescript-eslint/utils@8.46.4(eslint@9.39.2)(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/visitor-keys': 8.58.0 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.5.0(typescript@5.9.3) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) + '@typescript-eslint/scope-manager': 8.46.4 + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) + eslint: 9.39.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.0(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.0(eslint@9.39.2)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) '@typescript-eslint/scope-manager': 8.56.0 '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - eslint: 9.39.4 + eslint: 9.39.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/visitor-keys@8.46.4': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - eslint: 9.39.4 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color + '@typescript-eslint/types': 8.46.4 + eslint-visitor-keys: 4.2.1 '@typescript-eslint/visitor-keys@8.56.0': dependencies: '@typescript-eslint/types': 8.56.0 - eslint-visitor-keys: 5.0.1 - - '@typescript-eslint/visitor-keys@8.58.0': - dependencies: - '@typescript-eslint/types': 8.58.0 - eslint-visitor-keys: 5.0.1 + eslint-visitor-keys: 5.0.0 acorn-jsx@5.3.2(acorn@8.16.0): dependencies: @@ -1440,7 +1290,7 @@ snapshots: acorn@8.16.0: {} - ajv@6.14.0: + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -1463,29 +1313,23 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.2 + picomatch: 2.3.1 argparse@2.0.1: {} balanced-match@1.0.2: {} - balanced-match@4.0.4: {} - binary-extensions@2.3.0: {} - brace-expansion@1.1.13: + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.3: + brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.5: - dependencies: - balanced-match: 4.0.4 - braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -1541,26 +1385,26 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.4): + eslint-config-prettier@10.1.8(eslint@9.39.2): dependencies: - eslint: 9.39.4 + eslint: 9.39.2 - eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.1): + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2))(eslint@9.39.2)(prettier@3.8.1): dependencies: - eslint: 9.39.4 + eslint: 9.39.2 prettier: 3.8.1 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@9.39.4) + eslint-config-prettier: 10.1.8(eslint@9.39.2) - eslint-plugin-roblox-ts@1.4.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3): + eslint-plugin-roblox-ts@1.3.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3): dependencies: '@roblox-ts/luau-ast': 2.0.1 - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.46.4(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.4(eslint@9.39.2)(typescript@5.9.3) + eslint: 9.39.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -1574,23 +1418,23 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint-visitor-keys@5.0.1: {} + eslint-visitor-keys@5.0.0: {} - eslint@9.39.4: + eslint@9.39.2: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 + '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.2 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.14.0 + ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -1609,7 +1453,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.5 + minimatch: 3.1.2 natural-compare: 1.4.0 optionator: 0.9.4 transitivePeerDependencies: @@ -1637,15 +1481,27 @@ snapshots: fast-diff@1.3.0: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} fast-uri@3.1.0: {} - fdir@6.5.0(picomatch@4.0.4): + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.3 file-entry-cache@8.0.0: dependencies: @@ -1662,12 +1518,12 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.2 + flatted: 3.3.3 keyv: 4.5.4 - flatted@3.4.2: {} + flatted@3.3.3: {} - fs-extra@11.3.4: + fs-extra@11.3.3: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.0 @@ -1764,17 +1620,20 @@ snapshots: lodash.merge@4.6.2: {} - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.5 + merge2@1.4.1: {} - minimatch@3.1.5: + micromatch@4.0.8: dependencies: - brace-expansion: 1.1.13 + braces: 3.0.3 + picomatch: 2.3.1 - minimatch@9.0.9: + minimatch@3.1.2: dependencies: - brace-expansion: 2.0.3 + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 ms@2.1.3: {} @@ -1809,9 +1668,9 @@ snapshots: path-parse@1.0.7: {} - picomatch@2.3.2: {} + picomatch@2.3.1: {} - picomatch@4.0.4: {} + picomatch@4.0.3: {} prelude-ls@1.2.1: {} @@ -1823,9 +1682,11 @@ snapshots: punycode@2.3.1: {} + queue-microtask@1.2.3: {} + readdirp@3.6.0: dependencies: - picomatch: 2.3.2 + picomatch: 2.3.1 require-directory@2.1.1: {} @@ -1839,18 +1700,24 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + reusify@1.1.0: {} + roblox-ts@3.0.0: dependencies: '@roblox-ts/luau-ast': 2.0.0 '@roblox-ts/path-translator': 1.1.0 '@roblox-ts/rojo-resolver': 1.1.0 chokidar: 3.6.0 - fs-extra: 11.3.4 + fs-extra: 11.3.3 kleur: 4.1.5 resolve: 1.22.11 typescript: 5.5.3 yargs: 17.7.2 + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + semver@7.7.4: {} shebang-command@2.0.0: @@ -1883,14 +1750,14 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - ts-api-utils@2.5.0(typescript@5.9.3): + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -1898,13 +1765,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.58.0(eslint@9.39.4)(typescript@5.9.3): + typescript-eslint@8.56.0(eslint@9.39.2)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 + '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2)(typescript@5.9.3) + eslint: 9.39.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d8424bf..924b55f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,2 @@ packages: - packages/* -publicHoistPattern: - - "*@rbxts*" diff --git a/rokit.toml b/rokit.toml index ca169e2..331e403 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,6 +1,7 @@ [tools] wally = "UpliftGames/wally@0.3.2" -rojo = "rojo-rbx/rojo@7.7.0" -stylua = "johnnymorganz/stylua@2.5.2" -luau-lsp = "johnnymorganz/luau-lsp@1.70.1" -lute = "luau-lang/lute@1.0.0" +rojo = "rojo-rbx/rojo@7.6.1" +stylua = "johnnymorganz/stylua@2.3.1" +selene = "Kampfkarren/selene@0.30.0" +luau-lsp = "johnnymorganz/luau-lsp@1.62.0" +lute = "luau-lang/lute@0.1.0-nightly.20260219" diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..5a029a0 --- /dev/null +++ b/selene.toml @@ -0,0 +1,7 @@ +std = "roblox" +exclude = ["*/node_modules/*"] + +[lints] +global_usage = "allow" +shadowing = "allow" +incorrect_standard_library_use = "allow" diff --git a/tests/init.luau b/tests/init.luau new file mode 100644 index 0000000..823aed6 --- /dev/null +++ b/tests/init.luau @@ -0,0 +1 @@ +(require)("@self/run.server")