diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cd5741..912f2ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,13 @@ 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 tests + run: lute run test - name: Build models run: lute run build diff --git a/.luaurc b/.luaurc index c56c621..c48b0f2 100644 --- a/.luaurc +++ b/.luaurc @@ -1,10 +1,7 @@ { - "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 + "languageMode": "strict", + "aliases": { + "lute": "~/.lute/typedefs/1.0.0/lute", + "std": "~/.lute/typedefs/1.0.0/std" + } +} diff --git a/.lute/build.luau b/.lute/build.luau index f1b5876..b708003 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", { makeparents = true }) +fs.createDirectory("build") process.run({ "rojo", "build", "--output=build/charm.rbxm", "build.project.json" }, { stdio = "inherit" }) diff --git a/.lute/check.luau b/.lute/check.luau index 4117286..b5ad31b 100644 --- a/.lute/check.luau +++ b/.lute/check.luau @@ -1,10 +1,10 @@ local fs = require("@std/fs") -local net = require("@lute/net") +local net = require("@std/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,6 +16,7 @@ local analyze = process.run({ "--ignore=**/node_modules/**", "--base-luaurc=.luaurc", "--definitions=roblox.d.luau", + "--flag:LuauSolverV2=true", "packages", "tests", "benches", @@ -23,12 +24,10 @@ 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 selene.ok and stylua.ok and eslint.ok) then +if not (analyze.ok and stylua.ok and eslint.ok) then process.exit(1) end diff --git a/.lute/test.luau b/.lute/test.luau new file mode 100644 index 0000000..4abbbb9 --- /dev/null +++ b/.lute/test.luau @@ -0,0 +1 @@ +(require)("../tests/run.server") diff --git a/.npmrc b/.npmrc deleted file mode 100644 index b644580..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -public-hoist-pattern[]=*@rbxts* diff --git a/README.md b/README.md index 71d5318..07d9641 100644 --- a/README.md +++ b/README.md @@ -18,20 +18,20 @@ -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. +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. **Build game state from simple building blocks:** -- 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 +- 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 -**Want to learn more about signals?** +**Want to learn more about reactivity?** - 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://angular.dev/guide/signals +- https://github.com/roblox/signals [Migrating from an older version of Charm?](#migration) @@ -51,7 +51,7 @@ Charm is a state management library based on [fine-grained reactivity](https://d - [`subscribe(getter, callback)`](#subscribegetter-callback) - [`untracked(callback)`](#untrackedcallback) - [`batch(callback)`](#batchcallback) - - [`mapped(getter, mapper)`](#mappedgetter-mapper) + - [`mapped(getter, transform)`](#mappedgetter-transform) - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) - [`atom(initialValue, equals?)`](#atominitialvalue-equals) - [`trigger(callback)`](#triggercallback) @@ -472,11 +472,11 @@ end) --- -### `mapped(getter, mapper)` +### `mapped(getter, transform)` -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 `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 first value returned by the mapper is used as the new value: +The first value returned by the transform function 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 mapper returns two values, the second value is used as the new key: +If the transform function 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 and other development environments where the Luau optimization level is `O1` or lower. +The `strict` and `frozen` flags are automatically enabled in Roblox Studio. --- @@ -623,72 +623,99 @@ CharmSync = "littensy/charm-sync@VERSION" ### Quick Start -Start by specifying the signals that the server should sync to clients. For this example, we'll use the first and last name signals: +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: ```luau --- nameStore -local getName, setName = signal("John") -local getSurname, setSurname = signal("Doe") -local ageAtom = atom(20) +-- store.luau + +local getStatus, setStatus = signal("intermission") +local timerAtom = atom(20) +local getInventories, setInventories = signal({} :: { [Username]: Inventory }) return { - getName = getName, - setName = setName, - getSurname = getSurname, - setSurname = setSurname, - ageAtom = ageAtom, + getStatus = getStatus, + setStatus = setStatus, + timerAtom = timerAtom, + getInventories = getInventories, + setInventories = setInventories, } ``` -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. +#### Server setup -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. +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: ```luau -local function onPlayerAdded(player: Player) - -- Add signal getters, computed signals, atoms, or reactive objects +-- sync.server.luau + +playerReadyEvent.OnServerEvent:Connect(function(player) + -- Sync signal getters, computed signals, atoms, or reactive proxies server.addSignalsToClient(player, { - name = nameStore.getName, - surname = nameStore.getSurname, - age = nameStore.ageAtom, + 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), }) -end - -for _, player in Players:GetPlayers() do - onPlayerAdded(player) -end - -Players.PlayerAdded:Connect(onPlayerAdded) +end) Players.PlayerRemoving:Connect(function(player) server.removeClient(player) end) server.connect(function(player, updates) - -- Customize how you send state updates to clients + -- Customize how you replicate state updates to clients syncEvent:FireClient(player, updates) end) + +-- Changes now get replicated to clients +store.setStatus("started") +store.timerAtom(30) ``` -> [!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. +> [!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 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({ - name = nameStore.setName, - surname = nameStore.setSurname, - age = nameStore.ageAtom, + 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, }) -- 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) ``` --- @@ -701,37 +728,49 @@ 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 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). | +| 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). | --- ### `server.addSignalsToClient(client, getters)` -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. +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. -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. +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. ```luau -Players.PlayerAdded:Connect(function(player) +playerReadyEvent.OnServerEvent:Connect(function(player) server.addSignalsToClient(player, { - name = nameStore.getName, - surname = nameStore.getSurname, - age = nameStore.ageAtom, + status = store.getStatus, + timer = store.timerAtom, }) end) ``` -You're also allowed to create new signals to sync to specific players, as long as the key is unique to that player: +You're also allowed to create new signals to sync to specific players, as long as the key is unique to that signal: ```luau -server.addSignalsToClient(player, { - [`data-{player.UserId}`] = computed(function() - return getPlayerData(player.UserId) - end), -}) +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) ``` +> [!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)` @@ -740,11 +779,11 @@ The `removeSignalsFromClient` function unsubscribes the client from a list of ke ```luau server.addSignalsToClient(player, { - name = nameStore.getName, - surname = nameStore.getSurname, + status = store.getStatus, + timer = store.timerAtom, }) -server.removeSignalsFromClient(player, "surname") +server.removeSignalsFromClient(player, "status") ``` --- @@ -754,7 +793,7 @@ server.removeSignalsFromClient(player, "surname") 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 -Players.PlayerAdded:Connect(function(player) +playerReadyEvent.OnServerEvent:Connect(function(player) server.addSignalsToClient(player, signals) end) @@ -805,9 +844,11 @@ You can pass either writable signals, atoms, or [reactive objects](#reactiveinit ```luau client.addSignals({ - name = nameStore.setName, - surname = nameStore.setSurname, - age = nameStore.ageAtom, + 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, }) ``` @@ -819,11 +860,11 @@ Unsubscribes from each signal with the corresponding keys. The signals will reta ```luau client.addSignals({ - name = nameStore.setName, - surname = nameStore.setSurname, + status = store.setStatus, + timer = store.timerAtom, }) -client.removeSignals("name") +client.removeSignals("status") ``` --- @@ -836,8 +877,9 @@ You should call `patch` when receiving updates from the server from a remote eve ```luau client.addSignals({ - name = nameStore.setName, - surname = nameStore.setSurname, + status = store.setStatus, + timer = store.timerAtom, + [`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory, }) syncEvent.OnClientEvent:Connect(function(updates) @@ -853,8 +895,8 @@ If you have a lot of signals to sync between the server and clients, it might be ```luau local sharedState = { - name = signalToAtom(nameStore.getName, nameStore.setName), - surname = signalToAtom(nameStore.getSurname, nameStore.setSurname), + status = signalToAtom(store.getStatus, store.setStatus), + inventories = signalToAtom(store.getInventories, store.setInventories), } client.addSignals(sharedState) @@ -867,7 +909,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`. We chose to address this by representing deleted values with a special `None` symbol 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`. Charm addresses this by representing deleted values with a special `None` value 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. @@ -883,6 +925,9 @@ 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 @@ -938,6 +983,22 @@ 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 9c24c6e..ffb839d 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, elapsed in times do - 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`) end print(table.concat(results, " | ")) diff --git a/benches/mapped.luau b/benches/mapped.luau index 27ca6bd..695b7f5 100644 --- a/benches/mapped.luau +++ b/benches/mapped.luau @@ -3,55 +3,56 @@ local bench = require("./bench") local computed = Charm.computed local mapped = Charm.mapped -local setActiveSub = Charm.setActiveSub local signal = Charm.signal -type Entry = { number } +type Key = number +type Value = number local count = 512 -local initialState: { Entry } = table.create(count) +local initialState: { [Key]: Value } = table.create(count, 0) -for i = 1, count do - initialState[i] = table.create(64, i) +local function expensiveOperation() + for _ = 1, 100 do + continue + end end -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 } +local function simpleMapped( + source: () -> { [Key]: Value }, + mapper: (Value, Key) -> (Value, Key?) +): () -> { [Key]: Value } return computed(function() local state = source() local nextState = {} - 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 + for keyIn, valueIn in state do + local valueOut, keyOut = mapper(valueIn, keyIn) + nextState[if keyOut == nil then keyIn else keyOut] = valueOut end - return state + return nextState end) end -local function updatePercentage(percent: number): ({ Entry }) -> { Entry } +local function updateRandomly(updateChance: number): ({ [Key]: Value }) -> { [Key]: Value } return function(state) local nextState = table.clone(state) - for index, value in state do - if math.random() < percent then - nextState[index] = table.clone(value) + for key in state do + if math.random() < updateChance then + nextState[key] += 1 end end return nextState end end -for _, percent in { 0.1, 0.5, 1 } do - bench.run(`mapped {percent}`, function() +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() local src, setSrc = signal(initialState) local out = mapped(src, mapper) @@ -59,14 +60,14 @@ for _, percent in { 0.1, 0.5, 1 } do out() bench.profileend() - setSrc(updatePercentage(percent)) + setSrc(updateRandomly(updateChance)) bench.profilebegin("update") out() bench.profileend() end) - bench.run(`control {percent}`, function() + bench.run(`control {updateChance}`, function() local src, setSrc = signal(initialState) local out = simpleMapped(src, mapper) @@ -74,7 +75,7 @@ for _, percent in { 0.1, 0.5, 1 } do out() bench.profileend() - setSrc(updatePercentage(percent)) + setSrc(updateRandomly(updateChance)) bench.profilebegin("update") out() diff --git a/benches/multi.luau b/benches/multi.luau new file mode 100644 index 0000000..6c8235c --- /dev/null +++ b/benches/multi.luau @@ -0,0 +1,188 @@ +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 c2815eb..d0bf03d 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,29 @@ { "private": true, "scripts": { - "test": "lute run tests", + "test": "lute run test", "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@10.30.2", + "packageManager": "pnpm@12.8.1", "devDependencies": { - "@eslint/js": "^9.39.2", + "@eslint/js": "^9.39.4", "@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.908", - "@rbxts/vide": "^0.6.0", - "@typescript-eslint/eslint-plugin": "^8.56.0", - "@typescript-eslint/parser": "^8.56.0", - "eslint": "^9.39.2", + "@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", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", - "eslint-plugin-roblox-ts": "^1.3.1", + "eslint-plugin-roblox-ts": "^1.4.0", "prettier": "^3.8.1", "roblox-ts": "^3.0.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.56.0" + "typescript-eslint": "^8.58.0" } } diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index db09da2..f2c84a3 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.4.0-rc.6", + "version": "0.4.1", "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 165bbc6..973a983 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,6 +64,8 @@ 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 @@ -80,6 +82,8 @@ 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 @@ -90,7 +94,7 @@ end @param payloads The state updates received from the server. ]] -local function patch(payloads: { SyncPayload }) +local function applyPatches(payloads: { SyncPayload }) for _, payload in payloads do if payload.type == "init" then Charm.batch(applyInitPayload, payload.data) @@ -104,5 +108,5 @@ return { addSignals = addSignals, removeSignals = removeSignals, removeAllSignals = removeAllSignals, - patch = patch, + patch = applyPatches, } diff --git a/packages/charm-sync/src/config.luau b/packages/charm-sync/src/config.luau index a101f05..c3ce27c 100644 --- a/packages/charm-sync/src/config.luau +++ b/packages/charm-sync/src/config.luau @@ -9,19 +9,22 @@ 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`. + to `false` for performance reasons. ]] preserveHistory = false, --[[ - When `true`, Charm will apply validation and serialize unsafe arrays - to address remote event argument limitations. Defaults to `true`. + 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. + 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 throw an error. Only checked in strict mode and if + tables will emit a warning. 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 6519951..bb40eba 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -78,14 +78,17 @@ 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`. + * to `false` for performance reasons. * * @server */ preserveHistory: boolean; /** - * When `true`, Charm will apply validation and serialize unsafe arrays - * to address remote event argument limitations. Defaults to `true`. + * 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. + * * This option should be disabled if your network library uses a custom * serialization method (i.e. Zap, ByteNet) to prevent interference. * @@ -94,7 +97,7 @@ export const config: { fixArrays: boolean; /** * When `true`, synced state containing unsafe sparse arrays or mixed - * tables will throw an error. Only checked in strict mode and if + * tables will emit a warning. Only checked in strict mode and if * `fixArrays` is enabled. Defaults to `true`. * * @server @@ -242,7 +245,7 @@ export namespace patch { * @param statePatch The patches to apply. * @returns The new state with the patch applied. */ - export function apply( + export function applyImmutable( currentState: State, statePatch: SyncPatch, ): State; diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index b9a3be1..d6b940f 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: {}): { [string | number]: any } +local function stringifySparseArray(object: { [any]: any }): { [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 ce6fc9e..6485e18 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -39,9 +39,10 @@ local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())? support custom implementations of reactive proxies. ]] local function toRaw(value: T): T - if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then - value.__track() - return toRaw(value.__target) + 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) end return value end @@ -60,7 +61,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.effect(function() + local dispose = Charm.untracked(Charm.effect, function() local newValue = getter() local rawValue = toRaw(newValue) local isReactive = newValue ~= rawValue @@ -124,8 +125,8 @@ local function diffGlobalUpdateBuffer(): { any } local patchCount = #globalUpdates local patches = table.create(patchCount) - for _, update in globalUpdates do - table.insert(patches, patch.diff(update.oldValue, update.newValue)) + for index, update in globalUpdates do + patches[index] = patch.diff(update.oldValue, update.newValue) end return patches @@ -145,7 +146,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { for index, update in globalUpdates do local patchData = globalPatches[index] - if not patchData then + if patchData == nil then continue end @@ -153,15 +154,17 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { local status = clientKeys[key] if status == LISTENING_FOR_CHANGES then - table.insert(payloads, { + local payload: SyncPayload = { type = "patch", data = { [key] = patchData }, - }) + } + table.insert(payloads, payload) elseif status == PENDING_INITIAL_STATE then - table.insert(payloads, { + local payload: SyncPayload = { type = "init", data = { [key] = update.newValue }, - }) + } + table.insert(payloads, payload) clientKeys[key] = LISTENING_FOR_CHANGES end end @@ -228,11 +231,19 @@ local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any } end if next(initData) ~= nil then - table.insert(payloads, { type = "init", data = initData }) + local payload: SyncPayload = { + type = "init", + data = initData, + } + table.insert(payloads, payload) end if next(patchData) ~= nil then - table.insert(payloads, { type = "patch", data = patchData }) + local payload: SyncPayload = { + type = "patch", + data = patchData, + } + table.insert(payloads, payload) end return payloads diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau index a4dae84..4a49871 100644 --- a/packages/charm-sync/src/utils.luau +++ b/packages/charm-sync/src/utils.luau @@ -2,10 +2,6 @@ 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. @@ -13,15 +9,15 @@ type NestedTable = { @param input A nested table. @return A flattened table with slash-separated keys. ]] -local function flatten(input: {}): Flattened +local function flatten(input: { [any]: any }): Flattened local result = {} - local function visit(node: {}, path: string) + local function visit(node: { [any]: any }, 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 ac21161..dfba716 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -1,11 +1,11 @@ -local function isTableUnsafe(object: {}) - local key = next(object) +local function isTableUnsafe(object: { [any]: any }) + local firstKey = next(object) - if key == nil then + if firstKey == nil then return false end - local keyType = type(key) + local keyType = type(firstKey) local maxKey = 0 -- Keys must all be the same type @@ -31,13 +31,11 @@ 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: {}) +local function validate(value: { [any]: any }) if isTableUnsafe(value) then warn( - `Cannot sync tables unsupported by remote events! See the table below for more info.\n\n` - .. "This can be for the following reasons:\n" + "Cannot sync tables unsupported by remote events! 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 68d0483..076544e 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: {}): {} +local function mockArraySerdes(object: { [any]: any }): { [any]: any } 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 02a9af3..d24970c 100644 --- a/packages/charm-sync/test/server.test.luau +++ b/packages/charm-sync/test/server.test.luau @@ -4,6 +4,7 @@ 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() @@ -14,7 +15,7 @@ test("should replicate state changes", function() local a = Charm.atom(0) local b = Charm.atom(0) local c = Charm.atom(0) - local target, payloads + local target: unknown, payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a, b = b, c = c }) server.connect(function(...) @@ -41,7 +42,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, payloads + local target: unknown, payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a, b = b }) server.connect(function(...) @@ -68,7 +69,7 @@ end) test("should replicate partial state patches (reactive)", function() local client = {} local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? }) - local target, payloads + local target: unknown, payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a }) server.connect(function(...) @@ -118,7 +119,7 @@ test("should init new clients", function() local clientA = {} local clientB = {} local a = Charm.atom(0) - local payloadsA, payloadsB + local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } server.addSignalsToClient(clientA, { a = a }) server.connect(function(target, payloads) @@ -147,7 +148,7 @@ test("should init new atoms", function() local client = {} local a = Charm.atom(0) local b = Charm.atom(0) - local target, payloads + local target: unknown, payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a }) server.connect(function(...) @@ -172,7 +173,7 @@ test("should only replicate to synced clients", function() local clientB = {} local a = Charm.atom(0) local b = Charm.atom(0) - local payloadsA, payloadsB + local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } server.addSignalsToClient(clientA, { a = a }) server.addSignalsToClient(clientB, { b = b }) @@ -214,7 +215,7 @@ end) test("should serialize state", function() local client = {} local a = Charm.atom({ 1, 2, 3, 4, 5 }) - local payloads + local payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a }) server.connect(function(_, p) @@ -231,7 +232,7 @@ end) test("should not send unchanged state", function() local client = {} local a = Charm.atom(0) - local payloads + local payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a }) server.connect(function(_, p) @@ -262,7 +263,7 @@ test.describe("historical", function() local a = Charm.atom(0) local b = Charm.atom(0) local c = Charm.atom(0) - local target, payloads + local target: unknown, payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a, b = b, c = c }) server.connect(function(...) @@ -292,7 +293,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, payloads + local target: unknown, payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a, b = b, c = c }) server.connect(function(...) @@ -321,7 +322,7 @@ test.describe("historical", function() local clientA = {} local clientB = {} local a = Charm.atom(0) - local payloadsA, payloadsB + local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } server.addSignalsToClient(clientA, { a = a }) server.connect(function(target, payloads) @@ -357,7 +358,7 @@ test.describe("historical", function() local client = {} local a = Charm.atom(0) local b = Charm.atom(0) - local payloads + local payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a }) server.connect(function(_, ...) @@ -386,7 +387,7 @@ test.describe("historical", function() test("should not send unchanged state", function() local client = {} local a = Charm.atom(0) - local payloads + local payloads: { types.SyncPayload } server.addSignalsToClient(client, { a = a }) server.connect(function(_, p) @@ -402,6 +403,54 @@ 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 14c9734..1d50f55 100644 --- a/packages/charm-sync/test/utils.test.luau +++ b/packages/charm-sync/test/utils.test.luau @@ -6,9 +6,10 @@ 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(input) + + local result = utils.flatten<<{ [string]: Charm.Atom }>>(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 7fc9779..589e7ed 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.0-rc.6" +version = "0.4.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -12,4 +12,4 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.5" +Charm = "littensy/charm@^0.11.0" diff --git a/packages/charm/package.json b/packages/charm/package.json index e0701d7..7ef2e29 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.11.0-rc.5", + "version": "0.11.1", "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 ae37672..e4689e5 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 if the optimization level is below 2, which is true in studio. + * Enabled by default in Roblox Studio. */ strict: boolean; /** * Enforces immutability of tables by deep-freezing table values. Enabled - * if the optimization level is below 2, which is true in studio. + * by default in Roblox Studio. */ frozen: boolean; /** @@ -179,53 +179,50 @@ 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 fn The function to run when the value changes. + * @param callback 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, fn: (value: T, previousValue: T | undefined) => void): Cleanup; +export function listen(getter: Getter, callback: (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 fn The function to run when the value changes. + * @param callback 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, fn: (value: T, previousValue: T) => void): Cleanup; +export function subscribe(getter: Getter, callback: (value: T, previousValue: T) => void): Cleanup; /** * Creates a new read-only signal that is computed by mapping over the entries - * of an existing atom. If the key is omitted from the mapper's return value, - * the original key is preserved. + * of the source table. If the key is omitted from the transform function's + * return value, the original key is preserved. * - * @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 + * 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 */ export function mapped( getter: Getter | ReadonlyMap>, - mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, + transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, ): Getter>; // Overload for arrays export function mapped( getter: Getter, - mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, + transform: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, ): Getter; // Overload for objects export function mapped( getter: Getter<{ readonly [K in KI]: VI }>, - mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, + transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, ): Getter<{ readonly [K in KO]: VO }>; /** @@ -240,18 +237,18 @@ export function mapped( * from the table or when the callback is disposed. * * @param getter A function that returns the table to observe. - * @param fn A function that is called for each key in the table. + * @param callback 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[]>, - fn: (value: V, key: K) => Cleanup | void, + callback: (value: V, key: K) => Cleanup | void, ): Cleanup; // Overload for objects export function observe( getter: Getter<{ readonly [K in Key]: V }>, - fn: (value: V, key: K) => Cleanup | void, + callback: (value: V, key: K) => Cleanup | void, ): Cleanup; /** diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index c2c9275..0ba77d7 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -45,10 +45,11 @@ 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 changed (signal or computed) -local WATCHING = 0b000010 -- Watching for changes (effects) +local MUTABLE = 0b000001 -- Can be written to (signal or computed) +local WATCHING = 0b000010 -- Notified for changes in dependencies (effects) local RECURSED_CHECK = 0b000100 -- Being checked for circular dependencies -local DIRTY = 0b010000 -- Value has changed and needs updating +local RECURSED = 0b001000 -- Has been visited in a recursion check +local DIRTY = 0b010000 -- Value is outdated and needs recomputation local PENDING = 0b100000 -- Might be dirty, pending verification -- Global version counter for dependency link deduplication. Incremented at the @@ -57,7 +58,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 @@ -67,22 +68,22 @@ local batchDepth = 0 -- Dependencies accessed during this time will link to the active subscriber. local activeSub: ReactiveNode? -local function isO2(): boolean - return debug.info(1, "n") ~= "isO2" +local function isStudio(): boolean + return game ~= nil and game:GetService("RunService"):IsStudio() end local flags = { --[[ Enforces synchronous, non-yielding behavior in signals and effects. Also enables state validation in Charm Sync to catch sync errors early. - Enabled if the optimization level is below 2, which is true in studio. + Enabled by default in Roblox Studio. ]] - strict = not isO2(), + strict = isStudio(), --[[ Enforces immutability of tables by deep-freezing table values. Enabled - if the optimization level is below 2, which is true in studio. + by default in Roblox Studio. ]] - frozen = not isO2(), + frozen = isStudio(), --[[ Whether parent effects should track inner effects and clean them up when the parent effect re-runs. This is usually desirable, but can be @@ -92,37 +93,44 @@ local flags = { } --[[ - 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. + 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. ]] -local function noYield(fn: (Args...) -> Results...): (Args...) -> Results... +local function wrapUserSpace(callback: (Args...) -> Results...): (Args...) -> Results... if not flags.strict then - return fn + return callback end - local function resultHandler(thread: thread, ok: boolean, ...) + -- 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... if not ok then - error(debug.traceback(thread, tostring((...)))) + local err = (...) + if type(err) == "string" then + error(debug.traceback(thread, err), 0) + else + error(tostring(err), 0) + end elseif coroutine.status(thread) ~= "dead" then - error(debug.traceback(thread, "Yielding is not allowed in strict mode")) + error(debug.traceback(thread, "Attempted to yield in an effect or scope"), 0) + else + return ... end - return ... end - return function(...) - local thread = coroutine.create(fn) - return resultHandler(thread, coroutine.resume(thread, ...)) + return function(...: Args...): Results... + local thread = coroutine.create(callback) + return handler(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 @@ -135,8 +143,6 @@ end --[[ Returns the currently active subscriber (effect or computed node). - - @return Current active subscriber or `nil` ]] local function getActiveSub(): ReactiveNode? return activeSub @@ -145,9 +151,6 @@ 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 @@ -159,8 +162,6 @@ 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 @@ -172,33 +173,49 @@ end --[[ Runs all cleanup functions registered with the effect node. Any errors - thrown during cleanup are caught and logged as warnings. - - @param cleanups List of cleanup functions + thrown during cleanup are caught and logged in a single error after all + cleanups are complete. ]] local function runCleanups(cleanups: { Cleanup }) - for key, cleanup in cleanups do + local hasErrors = false + local errors: { string } = {} + + for index, cleanup in cleanups do + cleanups[index] = nil + local prevSub = setActiveSub(nil) - cleanups[key] = nil - xpcall(cleanup, function(err) - warn("Error occurred during effect cleanup:", debug.traceback(tostring(err), 2)) - end) + local success, err = pcall(cleanup) 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 flags = effect.flags + local effectFlags = effect.flags - if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then + if + bit32.btest(effectFlags, DIRTY) + or (bit32.btest(effectFlags, 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 @@ -209,10 +226,16 @@ local function run(effect: EffectNode) purgeDeps(effect) - if not success then - error(result) - elseif result then - table.insert(effect.cleanups, noYield(result)) + 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) end else effect.flags = WATCHING @@ -224,15 +247,35 @@ 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 - notifyIndex += 1 - run(effect) + effect.flags = bit32.bor(effect.flags, bit32.bor(WATCHING, RECURSED)) end notifyIndex = 0 queuedLength = 0 + + if not success then + error(err, 0) + end end --[[ @@ -249,17 +292,12 @@ end ]] local function endBatch() batchDepth -= 1 - if batchDepth == 0 then - flush() - end + flush() 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 @@ -278,16 +316,13 @@ local function updateComputed(computed: ComputedNode): boolean computed.value = newValue return oldValue ~= newValue else - error(newValue) + error(newValue, 0) 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 @@ -301,45 +336,27 @@ local function updateSignal(signal: SignalNode): boolean end --[[ - 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 + 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. ]] -local function clearPending(sub: ReactiveNode, flags: ReactiveFlags) - sub.flags = bit32.band(flags, bit32.bnot(PENDING)) -end +local function stopEffect(effect: EffectScopeNode) + effect.depsTail = nil + effect.flags = NONE ---[[ - Performs a thorough cleanup of the given effect or effect scope: + purgeDeps(effect) - 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 + -- Unlink from the parent effect, if it exists + local sub = effect.subs if sub then - unlink(sub, this) + unlink(sub, effect) end - runCleanups(this.cleanups) + runCleanups(effect.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 @@ -352,19 +369,20 @@ end --[[ Queues an effect for execution, ensuring inner effects are notified in the correct order. - - @param effect Effect node to notify ]] local function notify(effect: ReactiveNode) - local insertIndex = queuedLength - local firstInsertedIndex = insertIndex + local startIndex = queuedLength + 1 -- Collect inner effects in a chain repeat - queued[insertIndex] = effect :: EffectNode - insertIndex += 1 + queuedLength += 1 + queued[queuedLength] = effect :: EffectNode + -- 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 @@ -372,16 +390,14 @@ local function notify(effect: ReactiveNode) end until false - queuedLength = insertIndex + local endIndex = queuedLength - -- 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 + -- 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 end end @@ -389,10 +405,9 @@ 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 @@ -415,7 +430,7 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals ]] local function signal(initialValue: T, equals: Equals?): (Getter, Setter) - local this: SignalNode = { + local node: SignalNode = { currentValue = initialValue, pendingValue = initialValue, flags = MUTABLE, @@ -425,74 +440,71 @@ local function signal(initialValue: T, equals: Equals?): (Getter, Sette deepFreeze(initialValue) end - 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(noYield(update), this.pendingValue) - activeSub = prevSub - - if success then - value = result - else - error(result) - end - else - value = update - end - - -- Check if the new value is different from the pending value - if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then - if flags.frozen then - deepFreeze(value) - end - - this.pendingValue = value - this.flags = bit32.bor(MUTABLE, DIRTY) - - local subs = this.subs - if subs then - propagate(subs) - 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 + 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 active subscriber + -- 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(this, sub, cycle) + link(node, sub, cycle) break end local subs = sub.subs sub = if subs then subs.sub else nil end - return this.currentValue + return node.currentValue end - return readSignal, writeSignal + local function signalSetter(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) + activeSub = prevSub + + if success then + value = result + else + error(result, 2) + 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 flags.frozen then + deepFreeze(value) + end + + node.pendingValue = value + node.flags = bit32.bor(MUTABLE, DIRTY) + + local subs = node.subs + if subs then + propagate(subs) + flush() + end + end + + return node.pendingValue + end + + return signalGetter, signalSetter end --[[ @@ -506,9 +518,11 @@ 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 @@ -522,50 +536,57 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter ]] local function computed(getter: (previousValue: T?) -> T): Getter - local this: ComputedNode = { + local node: ComputedNode = { flags = NONE, - getter = noYield(getter), + getter = wrapUserSpace(getter), } - local function readComputed(): T - local flags = this.flags + local function computedOper(): T + local nodeFlags = node.flags - if - bit32.btest(flags, DIRTY) - or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or clearPending(this, flags))) - then - if updateComputed(this) then - -- Notify subscribers if the value changed - local subs = this.subs + if bit32.btest(nodeFlags, DIRTY) then + if updateComputed(node) then + local subs = node.subs if subs then shallowPropagate(subs) end end - elseif flags == NONE then + 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 -- First access should initialize the computed value - this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + node.flags = bit32.bor(MUTABLE, RECURSED_CHECK) - local prevSub = setActiveSub(this) - local success, result = pcall(this.getter) + local prevSub = setActiveSub(node) + local success, result = pcall(node.getter) activeSub = prevSub - this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK)) + node.flags = bit32.band(node.flags, bit32.bnot(RECURSED_CHECK)) if success then - this.value = result + node.value = result else - error(result) + error(result, 2) end end if activeSub then - link(this, activeSub, cycle) + link(node, activeSub, cycle) end - return this.value :: T + return node.value end - return readComputed + return computedOper end --[[ @@ -584,28 +605,38 @@ end @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback ]] local function effect(fn: () -> ...Cleanup?): Cleanup - local this: EffectNode = { - fn = noYield(fn), + local node: EffectNode = { + fn = wrapUserSpace(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(this, activeSub, 0) + link(node, activeSub, 0) end - local prevSub = setActiveSub(this) - local success, result = pcall(this.fn) + -- 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) activeSub = prevSub - if not success then - error(result) - elseif result then - table.insert(this.cleanups, noYield(result)) + if success and result then + table.insert(node.cleanups, wrapUserSpace(result)) + elseif not success then + endBatch() + error(result, 2) end + endBatch() + return function() - stopEffect(this) + stopEffect(node) end end @@ -614,32 +645,33 @@ 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/scope + @param detached If true, the scope will not be tracked by the parent effect or 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 this: EffectScopeNode = { + local node: EffectScopeNode = { flags = NONE, cleanups = {}, } + -- Inner effect scopes are tracked as a dependency of the parent effect if activeSub and not detached then - link(this, activeSub, 0) + link(node, activeSub, 0) end - local prevSub = setActiveSub(this) - local success, result = pcall(noYield(fn)) + local prevSub = setActiveSub(node) + local success, result = pcall(wrapUserSpace(fn)) activeSub = prevSub - if not success then - error(result) - elseif result then - table.insert(this.cleanups, noYield(result)) + if success and result then + table.insert(node.cleanups, wrapUserSpace(result)) + elseif not success then + error(result, 2) end return function() - stopEffect(this) + stopEffect(node) end end @@ -650,33 +682,31 @@ 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: () -> ()) +local function trigger(fn: () -> ...any) local sub: ReactiveNode = { flags = WATCHING, } local prevSub = setActiveSub(sub) - local success, result: any = pcall(noYield(fn)) + local success, err = pcall(wrapUserSpace(fn)) activeSub = prevSub + sub.flags = NONE - if not success then - error(result) - end - - local link = sub.deps - while link do - local dep = link.dep - link = unlink(link, sub) + local currentLink = sub.deps + while currentLink do + local dep = currentLink.dep local subs = dep.subs + currentLink = unlink(currentLink, sub) if subs then - sub.flags = NONE propagate(subs) shallowPropagate(subs) end end - if batchDepth == 0 then - flush() + flush() + + if not success then + error(err, 2) end end @@ -686,12 +716,12 @@ end set to `true`. @param fn Function to run during cleanup - @param failSilently If true, silences the warning when called outside an effect/scope + @param failSilently If true, silences the warning when called outside an effect or 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, noYield(fn)) + table.insert((activeSub :: EffectNode).cleanups, wrapUserSpace(fn)) elseif not failSilently then warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) end @@ -713,11 +743,13 @@ end local function untracked(fn: (Args...) -> Results..., ...: Args...): Results... if activeSub then local prevSub = setActiveSub(nil) - local results: { any } = { pcall(noYield(fn), ...) } + local results: { any } = { pcall(wrapUserSpace(fn), ...) } activeSub = prevSub + if not results[1] then - error(results[2]) + error(results[2], 2) end + return unpack(results, 2) else return fn(...) @@ -735,48 +767,44 @@ end ]] local function batch(fn: (Args...) -> Results..., ...: Args...): Results... startBatch() - local results: { any } = { pcall(noYield(fn), ...) } + local results: { any } = { pcall(wrapUserSpace(fn), ...) } endBatch() + if not results[1] then - error(results[2]) + error(results[2], 2) end + return unpack(results, 2) end --[[ - 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 + 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()`. ]] -local function toComputed(fn: Getter): Getter +local function isSignal(fn: Getter): boolean local name = debug.info(fn, "n") - return if name == "readSignal" or name == "readComputed" or name == "atomOper" then fn else computed(fn) + return name == "signalGetter" or name == "computedOper" or name == "atomOper" end --[[ - Creates an effect that runs the callback one immediately, and then again + Creates an effect that runs the callback once 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 fn Function to run when the value changes + @param callback 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, fn: (state: T, lastState: T?) -> ()): Cleanup - local tracked = toComputed(getter) +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 current: T return effect(function() local previous = current current = tracked() - untracked(fn, current, previous) + untracked(callback, current, previous) end) end @@ -785,16 +813,13 @@ 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 fn Function to run when the value changes + @param callback 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, fn: (state: T, lastState: T) -> ()): Cleanup - local tracked = toComputed(getter) +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 current: T local firstRun = true @@ -804,65 +829,68 @@ local function subscribe(getter: Getter, fn: (state: T, lastState: T) -> ( if firstRun then firstRun = false else - untracked(fn, current, previous) + untracked(callback, 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 fn Function that is called for each key added to the table + @param callback 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 }>, fn: (item: V, key: K) -> ...Cleanup?): Cleanup - local innerScopes: { [K]: Cleanup } = {} - local disposed = false +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 updateScopes(data: { [K]: V }) - for key, dispose in innerScopes do + for key, dispose in scopes do if data[key] == nil then - innerScopes[key] = nil + scopes[key] = nil dispose() - if disposed then + if stopObserving then return end end end for key, value in data do - if innerScopes[key] == nil then - local disposeScope = effectScope(function() - return fn(value, key) + if scopes[key] == nil then + local dispose = effectScope(function() + return callback(value, key) end, true) - if disposed then - return disposeScope() + if stopObserving then + dispose() + return end - innerScopes[key] = disposeScope + scopes[key] = dispose end end end return effectScope(function() effect(function() - -- 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()) + -- Wrap in untracked so that calling fn inside updateScopes does + -- not register dependencies to this effect + untracked(updateScopes, getter()) end) return function() - disposed = true - for key, dispose in innerScopes do - innerScopes[key] = nil + stopObserving = true + for key, dispose in scopes do + scopes[key] = nil dispose() end end @@ -871,65 +899,76 @@ end --[[ Creates a new read-only signal that is computed by mapping over the entries - of an existing atom. If the key is omitted from the mapper's return value, - the original key is preserved. + 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. @param getter A signal or function that returns a table - @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 + @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 ]] -local function mapped(getter: Getter<{ [KI]: VI }>, mapper: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> - local currentInput: { [KI]: VI } = {} - local keyCache: { [KI]: KO } = {} +local function mapped(getter: Getter<{ [KI]: VI }>, transform: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> + getter = wrapUserSpace(getter) + transform = wrapUserSpace(transform) - 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 + local nextInput: { [KI]: VI } = {} + local keyMap: { [KI]: KO } = {} - return computed(function(previousOutput) - local currentOutput: { [KO]: VO } = previousOutput or {} - local previousInput = currentInput - currentInput = getter() + return computed(function(lastOutput: { [KO]: VO }?): { [KO]: VO } + local nextOutput: { [KO]: VO } = lastOutput or {} + local prevInput = nextInput + nextInput = getter() - for keyIn in previousInput do - if currentInput[keyIn] ~= nil then + -- 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 continue end - local prevKeyOut = keyCache[keyIn] + local keyOut = keyMap[keyIn] - if prevKeyOut then - if currentOutput == previousOutput then - currentOutput = table.clone(currentOutput) + if keyOut ~= nil then + if nextOutput == lastOutput then + nextOutput = table.clone(nextOutput) end - currentOutput[prevKeyOut] = nil - keyCache[keyIn] = nil + nextOutput[keyOut] = nil + keyMap[keyIn] = nil end end - for keyIn, valueIn in currentInput do - if valueIn == previousInput[keyIn] then + -- 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 continue end - local valueOut, keyOut = map(valueIn, keyIn) + local nextValueOut, keyOutOrNil = transform(nextValueIn, keyIn) + local nextKeyOut: KO = if keyOutOrNil ~= nil then keyOutOrNil else keyIn + local prevKeyOut = keyMap[keyIn] - if currentOutput[keyOut] ~= valueOut then - if currentOutput == previousOutput then - currentOutput = table.clone(currentOutput) + if prevKeyOut ~= nil and prevKeyOut ~= nextKeyOut then + if nextOutput == lastOutput then + nextOutput = table.clone(nextOutput) end - currentOutput[keyOut] = valueOut - keyCache[keyIn] = keyOut + 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 end end - return currentOutput + return nextOutput end) end @@ -951,8 +990,8 @@ return { observe = observe, mapped = mapped :: (( getter: Getter<{ [KI]: VI }>, - mapper: (VI, KI) -> (VO, KO) - ) -> Getter<{ [KO]: VO }>) & ((getter: Getter<{ [K]: VI }>, mapper: (VI, K) -> VO) -> Getter<{ [K]: VO }>), + transform: (VI, KI) -> (VO, KO) + ) -> Getter<{ [KO]: VO }>) & ((getter: Getter<{ [K]: VI }>, transform: (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 10ad273..a01aa5e 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? - local sub = sub or link.sub + sub = sub or link.sub local dep = link.dep local prevDep = link.prevDep local nextDep = link.nextDep @@ -149,7 +149,6 @@ 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 4590807..c0095cf 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 + local c1 = computed(function(current: number?): number local incoming = src() return if current == incoming or incoming % 2 == 1 then current or incoming else incoming end) @@ -192,10 +192,29 @@ 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, v()) + assert(v() == 1, `expected 1 for v(), got {v()}`) v(2) assert(table.concat(calls, ",") == "0foo,2foo", table.concat(calls, ",")) - assert(v() == 2, v()) + 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) end) return {} diff --git a/packages/charm/test/effect.test.luau b/packages/charm/test/effect.test.luau index 9b526c4..17cd0a0 100644 --- a/packages/charm/test/effect.test.luau +++ b/packages/charm/test/effect.test.luau @@ -239,8 +239,11 @@ 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() @@ -268,7 +271,48 @@ test("should handle flags are indirectly updated during checkDirty", function() assert(triggers == 2, "did not update indirectly") end) -test("should allow recursion", function() +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() local src, setSrc = signal(0) local order = {} @@ -364,15 +408,186 @@ test("should run cleanups outside any evaluation context", function() assert(calls == 0, "should not run after b update") end) -test("infinite recursion should eventually throw", function() - local counter, setCounter = signal(0) +test("should not retrigger the effect if the cleanup modifies one of the dependencies", function() + local a, setA = signal(0) + local calls = {} - local success = pcall(function() + 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() effect(function() - setCounter(counter() + 1) + 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 end) end) - assert(not success, "should have thrown") + table.clear(order) + setSrc(true) + assert(table.concat(order) == "innerouter", `bad rerun order: {table.concat(order)}`) end) return {} diff --git a/packages/charm/test/effectScope.test.luau b/packages/charm/test/effectScope.test.luau index 932ebc1..964b7d5 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 stopScope = effectScope(function() effect(function() a() @@ -70,13 +70,26 @@ test("should run indirect effect cleanup", function() assert(cleanups == 2, "should not cleanup after update") end) -test("should disallow yielding", function() - local success = pcall(function() - effectScope(function() - (if task then task.wait else coroutine.yield :: never)() +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) end) + assert(triggers == 1, "did not run initially") end) - assert(not success, "should error on yield") + + setSource(2) + assert(triggers == 2, "did not run after update") + dispose() + setSource(3) + assert(triggers == 2, "ran after dispose") end) return {} diff --git a/packages/charm/test/errors.test.luau b/packages/charm/test/errors.test.luau new file mode 100644 index 0000000..151a0d5 --- /dev/null +++ b/packages/charm/test/errors.test.luau @@ -0,0 +1,200 @@ +--!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 2d53389..aed0af1 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: () -> ()) +local function unlink(getter: () -> unknown) effectScope(function() getter() end)() diff --git a/packages/charm/test/init.luau b/packages/charm/test/init.luau index e13fab7..52da435 100644 --- a/packages/charm/test/init.luau +++ b/packages/charm/test/init.luau @@ -12,6 +12,10 @@ 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 6792469..33620d4 100644 --- a/packages/charm/test/mapped.test.luau +++ b/packages/charm/test/mapped.test.luau @@ -104,4 +104,25 @@ 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 86efdc3..64bbd7d 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) + observe(items, function(_, key: number) 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 = {} + local edits: { string } = {} observe(items, function(item) if item < 5 then - setItems(function(state) + setItems(function(state: { number }) 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 = {} + local edits: { string } = {} observe(items, function(item, index) - setItems(function(state) + setItems(function(state: { number }) 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,4 +167,32 @@ 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 ebc8e51..21c1746 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, incoming) + local src, setSrc = signal(0, function(current: number, incoming: number) return current == incoming or incoming % 2 == 1 end) @@ -46,4 +46,22 @@ 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 6936c7a..10ab932 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 = {} + local order: { number } = {} 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 = {} + local order: { "even" | "odd" } = {} 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 06ee3c2..3675b3d 100644 --- a/packages/charm/test/topology.test.luau +++ b/packages/charm/test/topology.test.luau @@ -442,18 +442,16 @@ end) test.describe("error handling", function() test("should keep graph consistent on errors during activation", function() local a = atom(0) - local b = computed(function() + local b = computed(function(): nil error("fail") end) local c = computed(function() return a() end) - local success, result = pcall(function() - b() - end) + local success, result = pcall(b) assert(not success, "should throw") - assert(string.match(result, "fail"), "should throw fail") + assert(string.match(tostring(result), "fail"), "should throw fail") a(1) assert(c() == 1, "c should recover and return 1") @@ -461,7 +459,7 @@ test.describe("error handling", function() test("should keep graph consistent on errors in computeds", function() local a = atom(0) - local b = computed(function() + local b = computed(function(): number if a() == 1 then error("fail") end @@ -474,11 +472,9 @@ test.describe("error handling", function() assert(c() == 0, "c should be 0") a(1) - local success, result = pcall(function() - b() - end) + local success, result = pcall(b) assert(not success, "should throw") - assert(string.match(result, "fail"), "should throw fail") + assert(string.match(tostring(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 c548a86..20ebf32 100644 --- a/packages/charm/test/trigger.test.luau +++ b/packages/charm/test/trigger.test.luau @@ -3,6 +3,7 @@ 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 @@ -67,7 +68,7 @@ test("should trigger effect once", function() end) test("should not notify the trigger function sub", function() - local src1 = signal({}) + local src1 = signal({} :: { number }) local src2 = computed(function() return src1() end) @@ -90,9 +91,21 @@ 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 cd75c63..3c7fea4 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 4c76e65..25eb0a5 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,6 +1,6 @@ [package] name = "littensy/charm" -version = "0.11.0-rc.5" +version = "0.11.1" 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 96d06af..081b711 100644 --- a/packages/deep-charm/package.json +++ b/packages/deep-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/deep-charm", - "version": "0.1.0-rc.1", + "version": "0.1.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 ef9ab05..9e16cb1 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 value = value :: ReactiveProxy & T - value.__track() - return toRaw(value.__target) + local proxy = value :: ReactiveProxy + proxy.__track() + return toRaw(proxy.__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) else value + return if type(value) == "table" then reactive(value, trackParent) :: T else value :: T 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 4b747c9..41654b1 100644 --- a/packages/deep-charm/test/reactive.test.luau +++ b/packages/deep-charm/test/reactive.test.luau @@ -2,7 +2,10 @@ 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 @@ -49,10 +52,10 @@ test("should unwrap proxy assignments", function() end) src.a, src.b = src.a, src.b - assert(runs == 1, runs) + assert(runs == 1, `bad runs: {runs}`) src.a, src.b = src.b, src.a - assert(runs == 3, runs) + assert(runs == 3, `bad runs: {runs}`) end) test("should track iteration", function() @@ -125,10 +128,10 @@ test("should not track unread properties", function() local _ = src.value.a calls += 1 end) - assert(calls == 1, calls) + assert(calls == 1, `bad calls: {calls}`) src.value.b = 3 - assert(calls == 1, calls) + assert(calls == 1, `bad calls: {calls}`) end) test("should overwrite with table passed to setter", function() @@ -159,10 +162,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, runs) + assert(runs == 1, `bad runs: {runs}`) src.x = 1 - assert(runs == 2, runs) + assert(runs == 2, `bad runs: {runs}`) end) test("should unwrap nested proxy", function() @@ -177,13 +180,53 @@ test("should unwrap nested proxy", function() end) assert(toRaw(src) == raw, "unwrap did not return the original table") - assert(runs == 1, runs) + assert(runs == 1, `bad runs: {runs}`) inner.x = 1 - assert(runs == 2, runs) + assert(runs == 2, `bad runs: {runs}`) src.y = 2 - assert(runs == 3, runs) + 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)}`) end) return {} diff --git a/packages/deep-charm/wally.toml b/packages/deep-charm/wally.toml index bbd35f0..3f2702f 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.0-rc.1" +version = "0.1.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.5" +Charm = "littensy/charm@^0.11.0" diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index 4976c98..ca08eca 100644 --- a/packages/react-charm/package.json +++ b/packages/react-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.4.0-rc.3", + "version": "0.4.1", "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 205514a..08b5342 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: { any }?): T - local value, setValue = React.useState(getter) +local function useSignalState(getter: () -> T, dependencies: {}?): T + local value: T, setValue = React.useState(getter) - React.useLayoutEffect(function() - return Charm.listen(getter, setValue) - end, dependencies or {}) + React.useLayoutEffect(function(): () -> () + return Charm.listen(getter, setValue :: (T, T?) -> ()) + end, dependencies :: { any } 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: { any }?) +local function useSignalBinding(getter: () -> T, dependencies: {}?) local initialValue = React.useMemo(getter, {}) local binding, setBinding = React.useBinding(initialValue) - React.useLayoutEffect(function() - return Charm.listen(getter, setBinding) - end, dependencies or {}) + React.useLayoutEffect(function(): () -> () + return Charm.listen(getter, setBinding :: (T, T?) -> ()) + end, dependencies :: { any } or {}) return binding end diff --git a/packages/react-charm/test/helpers/renderHook.luau b/packages/react-charm/test/helpers/renderHook.luau index a059fb2..661d2e0 100644 --- a/packages/react-charm/test/helpers/renderHook.luau +++ b/packages/react-charm/test/helpers/renderHook.luau @@ -13,12 +13,13 @@ 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)) + root:render(React.createElement(Component, props) :: any) end) end diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index 12dd094..51ee9ab 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.0-rc.3" +version = "0.4.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.5" +Charm = "littensy/charm@^0.11.0" React = "jsdotlua/react@^17" diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json index 6a7ed72..9b64809 100644 --- a/packages/vide-charm/package.json +++ b/packages/vide-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.4.0-rc.3", + "version": "0.4.1", "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 db9d2e7..5a27f54 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)) + Vide.cleanup(Charm.listen(getter, state :: (State, State?) -> ())) return state end diff --git a/packages/vide-charm/test/useSignalState.test.luau b/packages/vide-charm/test/useSignalState.test.luau index 3bfc74f..9661501 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 + local source: () -> number 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 + local source: () -> number 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 8a15227..4aa1f71 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.0-rc.4" +version = "0.4.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0-rc.5" +Charm = "littensy/charm@^0.11.0" Vide = "centau/vide@^0.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 066affe..e5c8ddc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,186 @@ +--- +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: @@ -9,8 +192,8 @@ importers: .: devDependencies: '@eslint/js': - specifier: ^9.39.2 - version: 9.39.2 + specifier: ^9.39.4 + version: 9.39.4 '@rbxts/compiler-types': specifier: 3.0.0-types.0 version: 3.0.0-types.0 @@ -21,29 +204,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.908 - version: 1.0.908 + specifier: ^1.0.913 + version: 1.0.913 '@rbxts/vide': - specifier: ^0.6.0 - version: 0.6.0 + specifier: ^0.6.1 + version: 0.6.1 '@typescript-eslint/eslint-plugin': - 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) + 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) '@typescript-eslint/parser': - specifier: ^8.56.0 - version: 8.56.0(eslint@9.39.2)(typescript@5.9.3) + specifier: ^8.58.0 + version: 8.58.0(eslint@9.39.4)(typescript@5.9.3) eslint: - specifier: ^9.39.2 - version: 9.39.2 + specifier: ^9.39.4 + version: 9.39.4 eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.2) + version: 10.1.8(eslint@9.39.4) eslint-plugin-prettier: specifier: ^5.5.5 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2))(eslint@9.39.2)(prettier@3.8.1) + version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.1) eslint-plugin-roblox-ts: - 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) + 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) prettier: specifier: ^3.8.1 version: 3.8.1 @@ -54,8 +237,8 @@ importers: specifier: ^5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.56.0 - version: 8.56.0(eslint@9.39.2)(typescript@5.9.3) + specifier: ^8.58.0 + version: 8.58.0(eslint@9.39.4)(typescript@5.9.3) packages/charm: {} @@ -101,8 +284,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.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/config-helpers@0.4.2': @@ -113,12 +296,12 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.3': - resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.2': - resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -145,18 +328,6 @@ 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} @@ -235,12 +406,15 @@ packages: '@rbxts/react@17.3.7-ts.1': resolution: {integrity: sha512-/jqZ2TvyLSAPwC30h95loUmy4XN2PwPhv6fBARJKtZfyozFx2RuxC9FiQSjpEZ2uOOPzr60It0MBUf94D+tsMg==} - '@rbxts/types@1.0.908': - resolution: {integrity: sha512-b2nVXH4g/roLmOxVO3WYV2BdGaHnmX8BiGECwhuCmIClA3Do6t123/vhGmytiVhctHGUXD6VWMC1tX5rPlTGgQ==} + '@rbxts/types@1.0.913': + resolution: {integrity: sha512-AweuNYRVJb6y4PNajx2mnpqv+hm0+8GWtH9Dj4EMUuOVN5qcfcOMNSqjvQ4dmF5u0yQ89HKGeXa+4sLnaTIEng==} '@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==} @@ -259,26 +433,20 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@typescript-eslint/eslint-plugin@8.56.0': - resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} + '@typescript-eslint/eslint-plugin@8.58.0': + resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.56.0 + '@typescript-eslint/parser': ^8.58.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.56.0': - resolution: {integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==} + '@typescript-eslint/parser@8.58.0': + resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} 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.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: '>=4.8.4 <6.1.0' '@typescript-eslint/project-service@8.56.0': resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} @@ -286,19 +454,19 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.46.4': - resolution: {integrity: sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==} + '@typescript-eslint/project-service@8.58.0': + resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} 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/tsconfig-utils@8.46.4': - resolution: {integrity: sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==} + '@typescript-eslint/scope-manager@8.58.0': + resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} 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==} @@ -306,12 +474,11 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.46.4': - resolution: {integrity: sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==} + '@typescript-eslint/tsconfig-utils@8.58.0': + resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/type-utils@8.56.0': resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} @@ -320,19 +487,20 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.46.4': - resolution: {integrity: sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==} + '@typescript-eslint/type-utils@8.58.0': + resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} 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/typescript-estree@8.46.4': - resolution: {integrity: sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==} + '@typescript-eslint/types@8.58.0': + resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} 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==} @@ -340,12 +508,11 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.46.4': - resolution: {integrity: sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==} + '@typescript-eslint/typescript-estree@8.58.0': + resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/utils@8.56.0': resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} @@ -354,14 +521,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.46.4': - resolution: {integrity: sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==} + '@typescript-eslint/utils@8.58.0': + resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} 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: @@ -372,8 +546,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -396,15 +570,23 @@ 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.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + 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} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -483,8 +665,8 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-roblox-ts@1.3.1: - resolution: {integrity: sha512-+cXNQUlZF3sg/nS55DciRZeFiKpexGMrqXcO56mHf6TBpYEbfK+v/VboBPi8ZdaFyN5LgspnTh+t/Xi1AIvFJg==} + eslint-plugin-roblox-ts@1.4.0: + resolution: {integrity: sha512-OWj+JfQlvGSiAJ5YOmiQc+M29ZJFc7VFYP0U1J3orZNT9GP5CWfpRNqPHuDhsvz+ni0BwoI4aqGv2bIPDueGrQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=22.5.1} peerDependencies: '@typescript-eslint/parser': '>=6.0.0' @@ -503,12 +685,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.0: - resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.2: - resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -543,10 +725,6 @@ 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==} @@ -556,9 +734,6 @@ 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'} @@ -584,11 +759,11 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - fs-extra@11.3.3: - resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} engines: {node: '>=14.14'} fsevents@2.3.3: @@ -706,19 +881,15 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} ms@2.1.3: @@ -758,12 +929,12 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} prelude-ls@1.2.1: @@ -783,9 +954,6 @@ 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'} @@ -807,17 +975,10 @@ 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'} @@ -863,8 +1024,8 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -873,12 +1034,12 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.56.0: - resolution: {integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==} + typescript-eslint@8.58.0: + resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} 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.0.0' + typescript: '>=4.8.4 <6.1.0' typescript@5.5.3: resolution: {integrity: sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==} @@ -928,18 +1089,18 @@ packages: snapshots: - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2)': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': dependencies: - eslint: 9.39.2 + eslint: 9.39.4 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -951,21 +1112,21 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3': + '@eslint/eslintrc@3.3.5': dependencies: - ajv: 6.12.6 + ajv: 6.14.0 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.2 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.39.2': {} + '@eslint/js@9.39.4': {} '@eslint/object-schema@2.1.7': {} @@ -985,18 +1146,6 @@ 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': @@ -1107,10 +1256,12 @@ snapshots: dependencies: '@rbxts-js/react': 17.3.7-ts.1 - '@rbxts/types@1.0.908': {} + '@rbxts/types@1.0.913': {} '@rbxts/vide@0.6.0': {} + '@rbxts/vide@0.6.1': {} + '@roblox-ts/luau-ast@2.0.0': {} '@roblox-ts/luau-ast@2.0.1': {} @@ -1118,50 +1269,41 @@ snapshots: '@roblox-ts/path-translator@1.1.0': dependencies: ajv: 8.18.0 - fs-extra: 11.3.3 + fs-extra: 11.3.4 '@roblox-ts/rojo-resolver@1.1.0': dependencies: ajv: 8.18.0 - fs-extra: 11.3.3 + fs-extra: 11.3.4 '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} - '@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/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)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@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 + '@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 ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@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 + '@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 debug: 4.4.3 + eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -1175,67 +1317,60 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.46.4': + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/visitor-keys': 8.46.4 + '@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/scope-manager@8.56.0': dependencies: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/visitor-keys': 8.56.0 - '@typescript-eslint/tsconfig-utils@8.46.4(typescript@5.9.3)': + '@typescript-eslint/scope-manager@8.58.0': dependencies: - typescript: 5.9.3 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.46.4(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.58.0(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.2)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.0(eslint@9.39.4)(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.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.4)(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.2 - ts-api-utils: 2.4.0(typescript@5.9.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/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.56.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/types@8.58.0': {} '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': dependencies: @@ -1244,45 +1379,60 @@ snapshots: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 - minimatch: 9.0.5 + minimatch: 9.0.9 semver: 7.7.4 tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.46.4(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': dependencies: - '@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-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) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) '@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.2 + eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.46.4': + '@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.46.4 - eslint-visitor-keys: 4.2.1 + '@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/visitor-keys@8.56.0': dependencies: '@typescript-eslint/types': 8.56.0 - eslint-visitor-keys: 5.0.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 acorn-jsx@5.3.2(acorn@8.16.0): dependencies: @@ -1290,7 +1440,7 @@ snapshots: acorn@8.16.0: {} - ajv@6.12.6: + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -1313,23 +1463,29 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 + picomatch: 2.3.2 argparse@2.0.1: {} balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + binary-extensions@2.3.0: {} - brace-expansion@1.1.12: + brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@2.0.3: 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 @@ -1385,26 +1541,26 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.2): + eslint-config-prettier@10.1.8(eslint@9.39.4): dependencies: - eslint: 9.39.2 + eslint: 9.39.4 - eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2))(eslint@9.39.2)(prettier@3.8.1): + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.1): dependencies: - eslint: 9.39.2 + eslint: 9.39.4 prettier: 3.8.1 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@9.39.2) + eslint-config-prettier: 10.1.8(eslint@9.39.4) - 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): + 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): dependencies: '@roblox-ts/luau-ast': 2.0.1 - '@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-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: 5.9.3 transitivePeerDependencies: - supports-color @@ -1418,23 +1574,23 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint-visitor-keys@5.0.0: {} + eslint-visitor-keys@5.0.1: {} - eslint@9.39.2: + eslint@9.39.4: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3 - '@eslint/js': 9.39.2 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 '@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.12.6 + ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -1453,7 +1609,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 transitivePeerDependencies: @@ -1481,27 +1637,15 @@ 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: {} - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 file-entry-cache@8.0.0: dependencies: @@ -1518,12 +1662,12 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.2 keyv: 4.5.4 - flatted@3.3.3: {} + flatted@3.4.2: {} - fs-extra@11.3.3: + fs-extra@11.3.4: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.0 @@ -1620,20 +1764,17 @@ snapshots: lodash.merge@4.6.2: {} - merge2@1.4.1: {} - - micromatch@4.0.8: + minimatch@10.2.5: dependencies: - braces: 3.0.3 - picomatch: 2.3.1 + brace-expansion: 5.0.5 - minimatch@3.1.2: + minimatch@3.1.5: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.13 - minimatch@9.0.5: + minimatch@9.0.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.0.3 ms@2.1.3: {} @@ -1668,9 +1809,9 @@ snapshots: path-parse@1.0.7: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} prelude-ls@1.2.1: {} @@ -1682,11 +1823,9 @@ snapshots: punycode@2.3.1: {} - queue-microtask@1.2.3: {} - readdirp@3.6.0: dependencies: - picomatch: 2.3.1 + picomatch: 2.3.2 require-directory@2.1.1: {} @@ -1700,24 +1839,18 @@ 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.3 + fs-extra: 11.3.4 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: @@ -1750,14 +1883,14 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - ts-api-utils@2.4.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -1765,13 +1898,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.56.0(eslint@9.39.2)(typescript@5.9.3): + typescript-eslint@8.58.0(eslint@9.39.4)(typescript@5.9.3): dependencies: - '@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-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: 5.9.3 transitivePeerDependencies: - supports-color diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 924b55f..d8424bf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,4 @@ packages: - packages/* +publicHoistPattern: + - "*@rbxts*" diff --git a/rokit.toml b/rokit.toml index 331e403..ca169e2 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,7 +1,6 @@ [tools] wally = "UpliftGames/wally@0.3.2" -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" +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" diff --git a/selene.toml b/selene.toml deleted file mode 100644 index 5a029a0..0000000 --- a/selene.toml +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 823aed6..0000000 --- a/tests/init.luau +++ /dev/null @@ -1 +0,0 @@ -(require)("@self/run.server")