diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 912f2ea..af91488 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,19 +14,11 @@ jobs: run_install: true - uses: CompeyDev/setup-rokit@v0.1.2 - - name: Setup Lute - run: lute setup --with-luaurc - - name: Check code quality - run: lute run check - - - name: Run ESLint and Prettier - run: | - pnpm eslint packages - pnpm prettier --check packages + run: sh bin/check.sh - name: Run tests - run: lute run test + run: lune run test/spec.server.luau - name: Build models - run: lute run build + run: sh bin/build.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bd512d..5bdee21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,11 +11,8 @@ jobs: - uses: actions/checkout@v4 - uses: CompeyDev/setup-rokit@v0.1.2 - - name: Setup Lute - run: lute setup --with-luaurc - - name: Build models - run: lute run build + run: sh bin/build.sh - name: Upload release assets uses: AButler/upload-release-assets@v3.0 diff --git a/.gitignore b/.gitignore index d217e96..52e0af1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ /.pesde /build /packages/**/wally.lock -/packages/**/LICENSE* +/packages/**/LICENSE.md /packages/**/README.md /sourcemap.json node_modules diff --git a/.gitmodules b/.gitmodules index 409278b..e1cba9d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "tests/test"] - path = tests/test +[submodule "test/suite"] + path = test/suite url = https://gist.github.com/littensy/fa9afcc5d66d25e86538634982be4ee4 diff --git a/.luaurc b/.luaurc index c48b0f2..4c8043d 100644 --- a/.luaurc +++ b/.luaurc @@ -1,7 +1,6 @@ { "languageMode": "strict", - "aliases": { - "lute": "~/.lute/typedefs/1.0.0/lute", - "std": "~/.lute/typedefs/1.0.0/std" + "lint": { + "LocalShadow": false } } diff --git a/.lute/build.luau b/.lute/build.luau deleted file mode 100644 index b708003..0000000 --- a/.lute/build.luau +++ /dev/null @@ -1,6 +0,0 @@ -local fs = require("@std/fs") -local process = require("@lute/process") - -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 deleted file mode 100644 index b5ad31b..0000000 --- a/.lute/check.luau +++ /dev/null @@ -1,33 +0,0 @@ -local fs = require("@std/fs") -local net = require("@std/net") -local process = require("@lute/process") - -process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" }) - -fs.writeStringToFile( - "roblox.d.luau", - net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body -) - -local analyze = process.run({ - "luau-lsp", - "analyze", - "--sourcemap=sourcemap.json", - "--ignore=**/node_modules/**", - "--base-luaurc=.luaurc", - "--definitions=roblox.d.luau", - "--flag:LuauSolverV2=true", - "packages", - "tests", - "benches", -}, { stdio = "inherit" }) - -fs.remove("roblox.d.luau") - -local stylua = process.run({ "stylua", "--check", "packages", "tests", "benches" }, { stdio = "inherit" }) - -local eslint = process.run({ "pnpm", "eslint", "packages" }, { stdio = "inherit" }) - -if not (analyze.ok and stylua.ok and eslint.ok) then - process.exit(1) -end diff --git a/.lute/test.luau b/.lute/test.luau deleted file mode 100644 index 4abbbb9..0000000 --- a/.lute/test.luau +++ /dev/null @@ -1 +0,0 @@ -(require)("../tests/run.server") diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b644580 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +public-hoist-pattern[]=*@rbxts* diff --git a/LICENSE b/LICENSE deleted file mode 100644 index d10b611..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright 2024-present Littensy - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..ae1bb3d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,7 @@ +Copyright 2024 Littensy + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 07d9641..b5a7a2f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Charm

- Fine-grained reactivity for Roblox + Manage state with reactive signals
npm package →

@@ -14,26 +14,27 @@ ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/littensy/charm/ci.yml?style=for-the-badge&branch=main&logo=github) [![NPM Version](https://img.shields.io/npm/v/@rbxts/charm.svg?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@rbxts/charm) -[![GitHub License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE) +[![GitHub License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE.md) -Charm is a reactive state management library designed for storing data in discrete containers called _signals_. Connect behavior to data with signals, ensuring systems stay up-to-date with their underlying data while reactivity eliminates the need for manual updates. +**Charm** is a Roblox state management library inspired by the [reactive signals](https://preactjs.com/blog/introducing-signals/) from libraries like Preact and Solid. It's built on [alien-signals](https://github.com/stackblitz/alien-signals), modified for a wide range of use cases: from rendering user interfaces to handling game logic. -**Build game state from simple building blocks:** +**Charm works under a few core principles.** -- 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 +- Manage state with reactive signals: state containers that hold a value +- React to state updates: reading a signal automatically subscribes to it +- Combine multiple signals: derive new values from existing state that stay up-to-date +- Fine-grained reactivity: make targeted updates in response to specific changes in the state +- Data should be immutable: values are compared directly (`==`) to optimize change detection -**Want to learn more about reactivity?** +**Want to learn more about signals?** -- https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf - https://preactjs.com/blog/introducing-signals - https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity -- https://github.com/roblox/signals +- https://angular.dev/guide/signals -[Migrating from an older version of Charm?](#migration) +[Migrating from an older version?](#migration)
Table of Contents @@ -43,17 +44,18 @@ Charm is a reactive state management library designed for storing data in discre - [`signal(initialValue, equals?)`](#signalinitialvalue-equals) - [`computed(getter)`](#computedgetter) - [`effect(callback)`](#effectcallback) - - [Nested effects](#nested-effects) + - [`reactive(initialValue)`](#reactiveinitialvalue) + - [`untracked(callback)`](#untrackedcallback) + - [`peek(callback)`](#peekcallback) + - [`batch(callback)`](#batchcallback) - [`effectScope(callback)`](#effectscopecallback) - [`listen(getter, callback)`](#listengetter-callback) - - [Getter functions](#getter-functions) - - [`observe(getter, callback)`](#observegetter-callback) - [`subscribe(getter, callback)`](#subscribegetter-callback) - - [`untracked(callback)`](#untrackedcallback) - - [`batch(callback)`](#batchcallback) - - [`mapped(getter, transform)`](#mappedgetter-transform) + - [`observe(getter, callback)`](#observegetter-callback) + - [`mapped(getter, mapper)`](#mappedgetter-mapper) - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) - [`atom(initialValue, equals?)`](#atominitialvalue-equals) + - [`recursive()`](#recursive) - [`trigger(callback)`](#triggercallback) - [`flags`](#flags) - [Client-Server Sync](#client-server-sync) @@ -62,12 +64,6 @@ Charm is a reactive state management library designed for storing data in discre - [Server API](#config) - [Client API](#clientaddsignalssetters) - [Sync Caveats](#sync-caveats) -- [Deep Reactivity](#deep-reactivity) - - [Installation](#installation-2) - - [`reactive(initialValue)`](#reactiveinitialvalue) - - [Mutation vs. update function](#mutation-vs-update-function) - - [`toRaw(value)`](#torawvalue) - - [`isReactive(value)`](#isreactivevalue) - [Migration](#migration) - [Examples](#examples) @@ -76,19 +72,19 @@ Charm is a reactive state management library designed for storing data in discre ## At a Glance ```luau -local getTodos, setTodos = signal({} :: { string }) +local getTodos, setTodos = signal({} :: { [number]: string }) local getQuery, setQuery = signal("") -observe(getTodos, function(todo: string, index: number) +observe(getTodos, function(todo, index) local instance = Instance.new("TextLabel") - local getText = computed(function() - return getTodos()[index] or "" + local getText = computed(function(prevText) + return getTodos()[index] or prevText end) effect(function() instance.Text = getText() - instance.Visible = string.match(getText(), getQuery()) ~= nil + instance.Visible = getText():match(getQuery()) ~= nil end) instance.LayoutOrder = index @@ -101,31 +97,30 @@ observe(getTodos, function(todo: string, index: number) end) setTodos({ "Buy milk", "Buy eggs", "Play Roblox" }) -setQuery("Buy") +setQuery("^Buy") ```
Explain code ```luau --- Declare state for a todo list and a search query -local getTodos, setTodos = signal({} :: { string }) +local getTodos, setTodos = signal({} :: { [number]: string }) local getQuery, setQuery = signal("") -- Create a text label when an item is added to the list observe(getTodos, function(todo, index) local instance = Instance.new("TextLabel") - -- Create a memoized function that only re-runs effects when the todo list - -- updates or the function returns new text - local getText = computed(function() - return getTodos()[index] or "" + -- Create a computed signal that stores the current text, or the previous + -- value if the item was removed + local getText = computed(function(prevText) + return getTodos()[index] or prevText end) - -- Update instance properties when the text or query updates + -- Set properties when the text or query updates effect(function() instance.Text = getText() - instance.Visible = string.match(getText(), getQuery()) ~= nil + instance.Visible = getText():match(getQuery()) ~= nil end) instance.LayoutOrder = index @@ -140,8 +135,8 @@ end) -- Add items to the todo list setTodos({ "Buy milk", "Buy eggs", "Play Roblox" }) --- Filter for items containing "Buy" -setQuery("Buy") +-- Filter for items starting with "Buy" +setQuery("^Buy") ```
@@ -155,7 +150,6 @@ pnpm add @rbxts/charm ``` ```toml -# wally.toml [dependencies] Charm = "littensy/charm@VERSION" ``` @@ -171,11 +165,11 @@ Signals are the core of reactivity in Charm. The `signal` function creates a rea ```luau local getCounter, setCounter = signal(0) +print(getCounter()) -- 0 setCounter(1) setCounter(function(count) return count + 1 end) -print(getCounter()) -- 2 ``` Accessing the signal's value in an effect or computed signal will subscribe to it as a dependency. Changing the value will immediately notify every effect and computed signal that depends on the signal, ensuring all of your state is correct and up-to-date. @@ -193,7 +187,42 @@ setMax(-1) -- 2 ``` > [!NOTE] -> Looking for atoms? You can still use [`atom()`](#atominitialvalue-equals) to create a signal with a unified getter and setter. +> Looking for atoms? You can still use [`atom()`](#atominitialvalue-equals) to create a signal with a combined getter and setter. + +--- + +### `computed(getter)` + +The `computed` function creates a new signal with a value derived from multiple signals. The computed signal can be reacted to like a normal signal, and it returns the getter function's last return value. + +The computed signal will subscribe to signals accessed by the getter and track them as dependencies. Changes to these dependencies will re-execute the getter, and if it returns a new value, then the computed signal is updated to that value. + +```luau +local getName, setName = signal("John") +local getSurname, setSurname = signal("Doe") +local getFullName = computed(function() + return `{getName()} {getSurname()}` +end) + +print(getFullName()) -- "John Doe" +setName("Jane") +print(getFullName()) -- "Jane Doe" +``` + +The getter function also receives the previous state (or `nil` if running for the first time). You can use this for more complex state: + +```luau +local getCounter, setCounter = signal(10) +local getMax = computed(function(prevMax) + return math.max(getCounter(), prevMax or 0) +end) + +print(getMax()) -- 10 +setCounter(5) +print(getMax()) -- 10 +``` + +Computed signals are updated lazily, meaning the getter will only re-execute once the computed signal is called _and_ if a dependency updated since the last call. --- @@ -204,9 +233,10 @@ Effects are fundamental to reactivity, allowing you to react to signal updates. ```luau local getCounter, setCounter = signal(0) +-- Count is 0 effect(function() print(`Count is {getCounter()}`) -end) -- Count is 0 +end) setCounter(1) -- Count is 1 ``` @@ -227,29 +257,20 @@ setCounter(1) -- Cleanup 0 dispose() -- Cleanup 1 ``` -### Nested Effects - -An effect is _nested_ if it was created during the execution of another effect. In Charm, when an effect with nested effects re-runs or gets cleaned up, the nested effects from the previous run are automatically cleaned up and re-created if needed. This prevents memory leaks and ensures that outer effects always run before their inner effects: +Effects can be nested and they will clean up automatically when the parent effect re-runs or gets disposed. This means you don't have to manually clean up inner effects, and this applies to functions like `subscribe()` and `observe()`. ```luau -local getPrintCount, setPrintCount = signal(true) -local getCount, setCount = signal(1) +local getCounter, setCounter = signal(0) effect(function() - if getPrintCount() then - -- This inner effect is created when getPrintCount() is true - effect(function() - print(`Count is {getCount()}`) - end) - end -end) -- Count is 1 + print(`Outer: {getCounter()}`) + effect(function() + print(`Inner: {getcounter()}`) + end) +end) -setCount(2) -- Count is 2 - --- This re-runs the outer effect and cleans up old inner effects -setPrintCount(false) - -setCount(3) -- No output +setCounter(1) -- Outer: 1, Inner: 1 +setCounter(2) -- Outer: 2, Inner: 2 ``` > [!NOTE] @@ -257,40 +278,127 @@ setCount(3) -- No output --- -### `computed(getter)` +### `reactive(initialValue)` -The `computed` function creates a read-only signal whose value is derived from other signals. The computed signal caches the getter function's last result, and the value is only re-computed if a dependency has updated since the last computation. +By default, Charm's signals are _shallowly reactive_, meaning only the value itself is reactive, and table properties are not tracked. Signal values must also be **immutable** in order to track changes in tables. + +You can opt-in to deep reactivity with **reactive proxies**. The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and table values will also be wrapped in a reactive proxy. + +```luau +local users, setUsers = reactive({ + { name = "John", surname = "Doe" }, +}) + +-- Output: John Doe +effect(function() + print(`{users[1].name} {users[1].surname}`) +end) + +-- Output: Jane Smith +setUsers(function(state) + state[1].name = "Jane" + state[1].surname = "Smith" + table.insert(state, { name = "Steve", surname = "Doe" }) +end) + +-- You can also mutate the reactive proxy directly: +users[1].name = "John" -- John Smith +``` + +> [!WARNING] +> Because reactive proxies use metatables for reading and writing, functions like `table.insert` will not work on the proxy. Table functions should only be called on the raw table value. + +If you need to access the raw table through the reactive proxy, use the `toRaw()` function: + +```luau +local raw = {} +local proxy = reactive(raw) + +print(toRaw(proxy) == raw) -- true +``` + +--- + +### `untracked(callback)` + +In case you want to opt-out of dependency tracking in an effect, you can use `untracked()` to call a function _outside_ the current scope, preventing signals and effects in the function from being tracked. + +```luau +local getTracked, setTracked = signal(0) +local getUntracked, setUntracked = signal(0) + +-- Tracked: 0, Untracked: 0 +effect(function() + print(`Tracked: {getTracked()}, Untracked: {untracked(getUntracked)}`) +end) + +setTracked(1) -- Tracked: 1, Untracked: 0 +setUntracked(1) -- No output +setTracked(2) -- Tracked: 2, Untracked: 1 +``` + +Because `untracked()` executes the callback outside the current effect, nested effects created during the execution of the callback will not be tracked by the parent effect: + +```luau +local stopEffect +local stopScope = effectScope(function() + untracked(function() + stopEffect = effect(function() + return function() + print("Cleaned up untracked effect") + end + end) + end) +end) + +stopScope() -- No output, the scope did not track the effect +stopEffect() -- Cleaned up untracked effect +``` + +--- + +### `peek(callback)` + +Similar to `untracked()`, but does not prevent the parent effect from tracking nested effects created in the callback. If your callback reads a signal and creates effects, only the signals get untracked. + +```luau +local getCounter, setCounter = signal(0) + +-- Count is 0 +local disposeOuter = effect(function() + print(`Count is {peek(getCounter)}`) +end) + +setCounter(1) -- No output; count was accessed in peek() +``` + +--- + +### `batch(callback)` + +Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes. ```luau local getName, setName = signal("John") local getSurname, setSurname = signal("Doe") -local getFullName = computed(function() - return `{getName()} {getSurname()}` + +effect(function() + print(`Full name: {getName()} {getSurname()}`) end) -print(getFullName()) -- "John Doe" -setName("Jane") -print(getFullName()) -- "Jane Doe" -``` - -The getter function also receives the previous result (or `nil` during the initial run). You can use this for computed signals that depend on the previous result: - -```luau -local getCounter, setCounter = signal(10) -local getMax = computed(function(prevMax) - return math.max(getCounter(), prevMax or 0) +-- Combines both writes into a single update. +-- Once the callback completes, outputs "Full name: Foo Bar" +batch(function() + setName("Foo") + setSurname("Bar") end) - -print(getMax()) -- 10 -setCounter(5) -print(getMax()) -- 10 ``` --- ### `effectScope(callback)` -Scopes allow you to dispose multiple effects at once. The `effectScope` function creates a scope that tracks inner effects, so effects created during the execution of the callback will clean up when the scope disposes. +Scopes allow you to dispose multiple effects at once. The `effectScope` function creates an effect with no dependencies, and effects created inside the callback clean up when the effect scope disposes. ```luau local getCounter, setCounter = signal(0) @@ -322,77 +430,15 @@ The callback also receives the previous value, or `nil` when running for the fir ```luau local getCounter, setCounter = signal(0) +-- Count is 0 (was nil) listen(getCounter, function(count, prevCount) print(`Count is {count} (was {prevCount})`) -end) -- Count is 0 (was nil) +end) setCounter(1) -- Count is 1 (was 0) ``` -Note that the listener callback runs in `untracked()`, so nested effects are not cleaned up. - -### Getter functions - -In most Charm APIs, you can also subscribe to getter functions that call one or more signals, and they will automatically be tracked: - -```luau -local getCounter, setCounter = signal(0) - -local function floorCounter() - return math.floor(getCounter()) -end - -listen(floorCounter, function(count, prevCount) - print(`Floor of count is {count} (was {prevCount})`) -end) -- Floor of count is 0 (was nil) - -setCounter(0.5) -- Doesn't print anything, floor is still 0 -setCounter(1) -- Floor of count is 1 (was 0) -``` - ---- - -### `observe(getter, callback)` - -[Observers](https://sleitnick.github.io/RbxObservers/docs/observer-pattern) allow you to track the lifetime of a given state. The `observe` function executes the callback for every unique key added to a table, and disposes the callback when that key is removed. - -```luau -local getItems, setItems = signal({ a = 0, b = 0 }) - -observe(getItems, function(value, key) - print(`Added {key}`) - return function() - print(`Removed {key}`) - end -end) -- Added a, Added b - -setItems({ a = 0, c = 0 }) -- Removed b, Added c -``` - -The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed: - -```luau -local getItems, setItems = signal({}) - -local dispose = observe(getItems, function(value, key) - local getValue = computed(function(prevValue) - return getItems()[key] or prevValue - end) - - effect(function() - local value = getValue() - print(`Set {key} = {value}`) - return function() - print(`Cleanup {key} = {value}`) - end - end) -end) - -setItems({ a = 0, b = 0 }) -- Set a = 0, Set b = 0 -setItems({ a = 1, b = 0 }) -- Cleanup a = 0, Set a = 1 -setItems({ a = 1 }) -- Cleanup b = 0 -dispose() -- Cleanup a = 1 -``` +Note that nested effects can be created inside `listen`, and they will clean up automatically when the listener re-runs or gets disposed. --- @@ -411,72 +457,59 @@ end) setCounter(1) -- Count is 1 (was 0) ``` +Note that nested effects can be created inside `subscribe`, and they will clean up automatically when the subscription re-runs or gets disposed. + --- -### `untracked(callback)` +### `observe(getter, callback)` -In case you want to opt-out of dependency tracking in an effect, you can use `untracked()` to call a function _outside_ the current scope, preventing signals and effects in the function from being tracked. +[Observers](https://sleitnick.github.io/RbxObservers/docs/observer-pattern) allow you to track the lifetime of a given state. The `observe` function executes the callback for every unique key added to a table, and disposes the callback when that key is removed. ```luau -local getTracked, setTracked = signal(0) -local getUntracked, setUntracked = signal(0) +local getItems, setItems = signal({ a = 0, b = 0 }) -effect(function() - print(`Tracked: {getTracked()}, Untracked: {untracked(getUntracked)}`) -end) -- Tracked: 0, Untracked: 0 +-- Added a, Added b +observe(getItems, function(value, key) + print(`Added {key}`) + return function() + print(`Removed {key}`) + end +end) -setTracked(1) -- Tracked: 1, Untracked: 0 -setUntracked(1) -- No output -setTracked(2) -- Tracked: 2, Untracked: 1 +setItems({ a = 0, c = 0 }) -- Removed b, Added c ``` -Because `untracked()` executes the callback outside the current effect, nested effects created during the execution of the callback will not be tracked by the parent effect: +The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed: ```luau -local stopEffect -local stopScope = effectScope(function() - untracked(function() - stopEffect = effect(function() - return function() - print("Cleaned up untracked effect") - end - end) +local getItems, setItems = signal({ a = 0, b = 0 }) + +local dispose = observe(getItems, function(value, key) + local getValue = computed(function(prevValue) + return getItems()[key] or prevValue + end) + + effect(function() + local value = getValue() + print(`{key} = {value}`) + return function() + print(`Cleanup {key} = {value}`) + end end) end) -stopScope() -- No output, the scope did not track the effect -stopEffect() -- Cleaned up untracked effect +setItems({ a = 1, b = 0 }) -- Cleanup a = 0, a = 1 +setItems({ a = 1 }) -- Cleanup b = 0 +dispose() -- Cleanup a = 1 ``` --- -### `batch(callback)` +### `mapped(getter, mapper)` -Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes. +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. -```luau -local getName, setName = signal("John") -local getSurname, setSurname = signal("Doe") - -effect(function() - print(`Full name: {getName()} {getSurname()}`) -end) - --- Combines both writes into a single update. --- Once the callback completes, outputs "Full name: Foo Bar" -batch(function() - setName("Foo") - setSurname("Bar") -end) -``` - ---- - -### `mapped(getter, transform)` - -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 transform function is used as the new value: +The first value returned by the mapper is used as the new value: ```luau local getList, setList = signal({ "a", "b", "c" }) @@ -488,7 +521,7 @@ end) print(getUppercase()) -- { "A", "B", "C" } ``` -If the transform function returns two values, the second value is used as the new key: +If the mapper returns two values, the second value is used as the new key: ```luau local getList, setList = signal({ "a", "b", "c" }) @@ -506,7 +539,7 @@ print(getSwapped()) -- { a = 1, b = 2, c = 3 } The `onCleanup` function binds the callback to the currently running effect or effect scope. Multiple cleanup functions can be bound to the same effect. -Unless `failSilently` is set to `true`, this function will emit a warning if there is no active effect or scope. +If there is no active effect, a warning is logged, unless `failSilently` is set to `true`. ```luau local dispose = effectScope(function() @@ -549,6 +582,26 @@ max(-1) -- 1 --- +### `recursive()` + +By design, Charm's reactive system [does not allow recursion](https://github.com/stackblitz/alien-signals/issues/90#issuecomment-3489711800) in effects or computed signals. In case you need to opt out of recursion checks for an effect, you can call `recursive()` at the top of the effect callback: + +```luau +local getCounter, setCounter = signal(0) + +effect(function() + recursive() + print(getCounter()) + setCounter(function(count) + return math.min(count + 1, 3) + end) +end) + +-- Output: 0, 1, 2, 3 +``` + +--- + ### `trigger(callback)` The `trigger()` function allows you to manually trigger updates for downstream dependencies when you've directly mutated a signal's value without using the signal setter: @@ -596,13 +649,13 @@ print(total()) -- 2 Charm exposes the following global flags to customize behavior: -| Flag | Default | Description | -| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| strict | `true`\* | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. | -| 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. | +| Flag | Default | Description | +| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| strict | `true`/`false` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. | +| frozen | `true`/`false` | Enforces data immutability by deep-freezing tables passed to signals. | +| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. | -The `strict` and `frozen` flags are automatically enabled in Roblox Studio. +The `strict` and `frozen` flags are automatically enabled in Roblox Studio. More accurately, they are enabled for the Luau optimization levels `O1` and lower. --- @@ -623,99 +676,72 @@ CharmSync = "littensy/charm-sync@VERSION" ### Quick Start -Start by specifying the signals that the server should sync to clients. For this example, we'll replicate a round status and timer signal, and each player's inventory: +Start by specifying the signals that the server should sync to clients. For this example, we'll use the first and last name signals: ```luau --- store.luau - -local getStatus, setStatus = signal("intermission") -local timerAtom = atom(20) -local getInventories, setInventories = signal({} :: { [Username]: Inventory }) +-- nameStore +local getName, setName = signal("John") +local getSurname, setSurname = signal("Doe") +local ageAtom = atom(20) return { - getStatus = getStatus, - setStatus = setStatus, - timerAtom = timerAtom, - getInventories = getInventories, - setInventories = setInventories, + getName = getName, + setName = setName, + getSurname = getSurname, + setSurname = setSurname, + ageAtom = ageAtom, } ``` -#### Server setup +When a player joins on the server, call `server.addSignalsToClient` with the keyed signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates. -When a player notifies the server that they're ready to start syncing, call `server.addSignalsToClient` with the signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates. - -Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to: +Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to. ```luau --- sync.server.luau - -playerReadyEvent.OnServerEvent:Connect(function(player) - -- Sync signal getters, computed signals, atoms, or reactive proxies +local function onPlayerAdded(player: Player) + -- Add signal getters, computed signals, atoms, or reactive objects server.addSignalsToClient(player, { - status = store.getStatus, - timer = store.timerAtom, - -- Signals unique to a player should also have a key unique to - -- that player, to avoid overwriting other synced signals - [`inventory-{player.UserId}`] = computed(function() - return store.getInventories()[player.Name] - end), + name = nameStore.getName, + surname = nameStore.getSurname, + age = nameStore.ageAtom, }) -end) +end + +for _, player in Players:GetPlayers() do + onPlayerAdded(player) +end + +Players.PlayerAdded:Connect(onPlayerAdded) Players.PlayerRemoving:Connect(function(player) server.removeClient(player) end) server.connect(function(player, updates) - -- Customize how you replicate state updates to clients + -- Customize how you send state updates to clients syncEvent:FireClient(player, updates) end) - --- Changes now get replicated to clients -store.setStatus("started") -store.timerAtom(30) ``` -> [!WARNING] -> On the server, make sure each key corresponds to the same signal across all players. In other words, if you're syncing a signal that is unique to a player, ensure that the key is also unique to that player. -> -> If two players subscribe to the same key, but were assigned different signals for that key, Charm will output a warning. - -#### Client setup +> [!NOTE] +> On the server, make sure each key corresponds to the same signal across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning. To sync the client with the server's state, call `client.addSignals` with a table of writable signals (setter functions or atoms) whose keys match their server counterparts. After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates. ```luau --- sync.client.luau - --- The server is replicating a signal containing the client's --- inventory, so this signal will receive those updates -local getInventory, setInventory = signal({} :: Inventory) - --- Add signal setters, atoms, or reactive proxies +-- Add signal setters, atoms, or reactive objects client.addSignals({ - status = store.setStatus, - timer = store.timerAtom, - -- Signals unique to a player should also have a key unique to - -- that player, to avoid name collisions - [`inventory-{Players.LocalPlayer.UserId}`] = setInventory, + name = nameStore.setName, + surname = nameStore.setSurname, + age = nameStore.ageAtom, }) -- Update client signals when receiving updates from the server syncEvent.OnClientEvent:Connect(function(updates) client.patch(updates) end) - --- Notify the server that the client is ready to receive updates -playerReadyEvent:FireServer() - --- The signals will now update to match the server's state -subscribe(getInventory, function(inventory) - print("My inventory is", table.concat(inventory)) -end) ``` --- @@ -728,49 +754,37 @@ A configuration table that customizes the behavior of Charm Sync on the server. | --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | interval | `0` | The frequency at which the server will send patches to the client, in seconds. A value of `0` sends updates on the next frame. Set to a negative value to disable the interval. | | preserveHistory | `false` | Whether to preserve a full history of state changes since the last sync, at the cost of performance. This is useful if you need to replicate each individual change that occurs between sync events. | -| fixArrays | `true` | When `true`, Charm will attempt to work around remote event limitations that cause data loss in array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). | -| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, synced values containing unsafe sparse arrays or mixed tables will emit a warning. See the [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations). | +| fixArrays | `true` | When `true`, Charm will attempt to work around Roblox remote event limitations regarding array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). | +| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, synced values containing unsafe sparse arrays or mixed tables will throw an error. See the [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations). | --- ### `server.addSignalsToClient(client, getters)` -The `addSignalsToClient` function replicates changes in the given signals to a specific client. When an update occurs, the client will receive a delta-compressed state patch containing only the values that changed. +The `addSignalsToClient` function subscribes a client to updates in the given signals. When an update occurs, the client will receive a state patch of only the values that changed. -You can pass signal getter functions, computed signals, atoms, and [reactive proxies](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals. +You can pass signal getter functions, computed signals, atoms, and [reactive objects](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals. ```luau -playerReadyEvent.OnServerEvent:Connect(function(player) +Players.PlayerAdded:Connect(function(player) server.addSignalsToClient(player, { - status = store.getStatus, - timer = store.timerAtom, + name = nameStore.getName, + surname = nameStore.getSurname, + age = nameStore.ageAtom, }) end) ``` -You're also allowed to create new signals to sync to specific players, as long as the key is unique to that signal: +You're also allowed to create new signals to sync to specific players, as long as the key is also unique to that player: ```luau -playerReadyEvent.OnServerEvent:Connect(function(player) - server.addSignalsToClient(player, { - -- 🟢 Good: Player-specific signals are synced with a unique key - [`playerData-{player.UserId}`] = computed(function() - return clientStore.getPlayerData(player.UserId) - end), - - -- 🔴 Bad: Syncing different signals with the same keys does not work - playerData = computed(function() - return clientStore.getPlayerData(player.UserId) - end), - }) -end) +server.addSignalsToClient(player, { + [`data-{player.UserId}`] = computed(function() + return getPlayerData(player.UserId) + end), +}) ``` -> [!NOTE] -> On the server, make sure each key corresponds to the same signal across all players. In other words, if you're syncing a signal that is unique to a player, ensure that the key is also unique to that player. -> -> If two players subscribe to the same key, but were assigned different signals for that key, Charm will output a warning. - --- ### `server.removeSignalsFromClient(client, ...keys)` @@ -779,11 +793,11 @@ The `removeSignalsFromClient` function unsubscribes the client from a list of ke ```luau server.addSignalsToClient(player, { - status = store.getStatus, - timer = store.timerAtom, + name = nameStore.getName, + surname = nameStore.getSurname, }) -server.removeSignalsFromClient(player, "status") +server.removeSignalsFromClient(player, "surname") ``` --- @@ -793,7 +807,7 @@ server.removeSignalsFromClient(player, "status") The `removeClient` function unsubscribes a client from receiving all state updates from the server. You should call this function when a player leaves the game. ```luau -playerReadyEvent.OnServerEvent:Connect(function(player) +Players.PlayerAdded:Connect(function(player) server.addSignalsToClient(player, signals) end) @@ -844,11 +858,9 @@ You can pass either writable signals, atoms, or [reactive objects](#reactiveinit ```luau client.addSignals({ - status = store.setStatus, - timer = store.timerAtom, - -- Signals unique to a player should also have a key unique to - -- that player, to avoid name collisions - [`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory, + name = nameStore.setName, + surname = nameStore.setSurname, + age = nameStore.ageAtom, }) ``` @@ -860,11 +872,11 @@ Unsubscribes from each signal with the corresponding keys. The signals will reta ```luau client.addSignals({ - status = store.setStatus, - timer = store.timerAtom, + name = nameStore.setName, + surname = nameStore.setSurname, }) -client.removeSignals("status") +client.removeSignals("name") ``` --- @@ -877,9 +889,8 @@ You should call `patch` when receiving updates from the server from a remote eve ```luau client.addSignals({ - status = store.setStatus, - timer = store.timerAtom, - [`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory, + name = nameStore.setName, + surname = nameStore.setSurname, }) syncEvent.OnClientEvent:Connect(function(updates) @@ -895,8 +906,8 @@ If you have a lot of signals to sync between the server and clients, it might be ```luau local sharedState = { - status = signalToAtom(store.getStatus, store.setStatus), - inventories = signalToAtom(store.getInventories, store.setInventories), + name = signalToAtom(nameStore.getName, nameStore.setName), + surname = signalToAtom(nameStore.getSurname, nameStore.setSurname), } client.addSignals(sharedState) @@ -909,124 +920,31 @@ server.addSignalsToClient(client, sharedState) Charm Sync will only send clients the differences between the current state and the last-synced state through a process called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`. -But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. Charm addresses this by representing deleted values with a special `None` value denoted by `{ __none = "__none" }`. +But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. We chose to address this by representing deleted values with a special `None` symbol denoted by `{ __none = "__none" }`. This means nilable values may be replaced with `None` in patches, and code working with update payloads (usually for remote argument serialization) should account for nilable values possibly being sent as `None` in the payload. --- -## Deep Reactivity - -Charm's reactivity system is _shallow_ by default: only the top-level value is reactive, so table properties are not checked when determining whether a signal has updated. As a result, tables in Charm should be immutable (copied before writing) in order to signal that a table's properties have changed. - -Deep reactivity, on the other hand, uses proxy tables to perform dependency tracking on properties. You can subscribe to a property by indexing the proxy table, and setting a property will notify its subscribers. This approach to reactivity lets you work with mutable data, making state management more intuitive at the cost of added overhead. - -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 -pnpm add @rbxts/deep-charm -``` - -```toml -[dependencies] -DeepCharm = "littensy/deep-charm@VERSION" -``` - ---- - -### `reactive(initialValue)` - -The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and nested tables will also be wrapped in a reactive proxy. - -```luau -local users, updateUsers = reactive({ - { name = "John", surname = "Doe" }, -}) - -effect(function() - print(`{users[1].name} {users[1].surname}`) -end) -- Output: John Doe - -updateUsers(function(raw) - raw[1].name = "Jane" - raw[1].surname = "Smith" - table.insert(raw, { name = "Steve", surname = "Doe" }) -end) -- Output: Jane Smith - --- You can also mutate the reactive proxy directly: -users[1].name = "John" -- John Smith -``` - -### Mutation vs. update function - -Because reactive proxies use metatables for reading and writing, functions like `table.insert` will not work on the proxy. Table functions should only be called on the raw table value, which you can access through the update function returned by `reactive()`. - -This update function (`updateUsers()` in the example above) passes the raw table for you to mutate. Once your callback is done executing, the updater will manually notify subscriptions to the reactive proxy and its nested properties. This process does not use metatables, so you should use this for table operations or batching updates. - ---- - -### `toRaw(value)` - -If you need to access the raw table through the reactive proxy, use the `toRaw()` function: - -```luau -local raw = {} -local proxy = reactive(raw) - -print(toRaw(proxy) == raw) -- true -``` - -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)` - -The `isReactive()` function returns `true` if the given value is a reactive proxy by checking its metatable. - -```lua -local raw = {} -local proxy = reactive(raw) - -print(isReactive(proxy)) -- true -print(isReactive(raw)) -- false -``` - ---- - ## Migration -Charm v0.11 introduces several breaking changes, so this section should help you migrate from an older version. +Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that might help you migrate from an older version. -For reference, a signal is a state container with a separated getter and setter function. Atoms are a signal with a single function for getting and setting the state. +
+Terminology changes + +- Signal: A state container with one function to get the state, and another to update it +- Atom: A signal with the getter and setter combined into one function +- Effect: Main way to react to state changes +- `subscribe(getter, callback)`: Creates an effect that only subscribes to signals accessed in the getter + +
**What to look out for:** 1. Address all of the type errors introduced in your project after updating Charm. Most of them are caused by changes like: - - `peek()` was changed to `untracked()` for parity with other state managers - The second arguments of `atom()` changed from an `options` table to an equality function - - Removed the second argument of `computed()` (you can do your own equality checks since the computed callback now receives the previous value) + - Removed the second argument of `computed()` (you can do your own equality checks now) - Removed the cleanup argument in effect callbacks (`effect(function(cleanup) end)`) 2. If you use Charm Sync, you'll have to rewrite a lot of your sync code. Fortunately, most of the changes should make your code _less_ complicated: @@ -1039,15 +957,24 @@ For reference, a signal is a state container with a separated getter and setter - `frozen`: Tables passed to signals are deeply frozen to strictly enforce data immutability and prevent accidental mutations 4. Nested effects automatically clean up when the parent effect re-runs or gets disposed. In other words, all effects created during the execution of another effect will be added as a "child" and clean up with the parent effect. This might cause issues in code that relied on the old behavior, where effects were detached from the parent. - - This feature applies to the observer function in `observe()`, which also automatically cleans up inner effects to prevent memory leaks. However, this does not apply to `subscribe()` or `listen()`. + - This feature applies to all reaction APIs, including the listener function in `subscribe()` and the observer function in `observe()`. - Effects that should not be tracked by a parent effect/scope should be wrapped in [`untracked()`](#untrackedcallback). - - This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can temporarily disable this feature by setting [`flags.trackInnerEffects`](#flags) to `false`. + - This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can disable this feature by setting [`flags.trackInnerEffects`](#flags) to `false`. -5. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)! - - [`signal()`](#signalinitialvalue-equals): make atom reads and writes more explicit - - [`listen()`](#listengetter-callback): create a subscription that also runs once immediately +> [!NOTE] +> An example of nested effects causing a bug is an old implementation of [`VideCharm.useAtom`](./packages/vide-charm/src/init.luau) that did not wrap the source update in `untracked()`. Because Vide effects run immediately after a source updates, Vide will notify components during the execution of the Charm effect in `useAtom`. +> +> This meant effects created as a side effect of a source update would implicitly get added as a child of the `useAtom` effect, and they could get disposed at the wrong time and desync UI. + +5. Recursion is now disallowed in effects and computed signals by default. This change may introduce bugs in code relying on the old behavior. + - Recursive checks are opt-out. You can allow recursion for a specific effect by calling [`recursive()`](#recursive) at the top of the effect callback. + +6. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)! + - [`reactive()`](#reactiveinitialvalue): make mutable tables deeply reactive + - [`signal()`](#signalinitialvalue-equals): make reads and writes more explicit + - [`listen()`](#listengetter-callback): create a subscription that runs once immediately - [`effectScope()`](#effectscopecallback): collect and clean up multiple effects at once - - [`onCleanup()`](#oncleanupcallback-failsilently): bind a cleanup function to the active effect or scope + - [`onCleanup()`](#oncleanupcallback-failsilently): bind a cleanup function to the active effect - [`trigger()`](#triggercallback): trigger updates for table mutations --- @@ -1059,14 +986,10 @@ For reference, a signal is a state container with a separated getter and setter ### React Counter ```luau -local Charm = require("@packages/charm") -local ReactCharm = require("@packages/react-charm") -local React = require("@packages/react") - -local getCounter, setCounter = Charm.signal(0) +local getCounter, setCounter = signal(0) local function Counter() - local count = ReactCharm.useSignalState(getCounter) + local count = useSignalState(getCounter) return React.createElement("TextButton", { [React.Event.Activated] = function() @@ -1081,16 +1004,10 @@ end ### Vide Counter ```luau -local Charm = require("@packages/charm") -local VideCharm = require("@packages/vide-charm") -local Vide = require("@packages/vide") - -local create = Vide.create - -local getCounter, setCounter = Charm.signal(0) +local getCounter, setCounter = signal(0) local function Counter() - local count = VideCharm.useSignalState(getCounter) + local count = useSignalState(getCounter) return create "TextButton" { Activated = function() @@ -1109,11 +1026,11 @@ end ---

-Charm is released under the MIT License. +Charm is released under the MIT License.

-[![MIT License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE) +[![MIT License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE.md)
diff --git a/benches/bench.luau b/benches/bench.luau index ffb839d..9c24c6e 100644 --- a/benches/bench.luau +++ b/benches/bench.luau @@ -20,8 +20,8 @@ local function run(name: string, test: () -> ()) table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`) - for name, duration in times do - table.insert(results, `{name}: \t{string.format("%.2f", duration / iter * 1000 * 1000)} us/iter`) + for name, elapsed in times do + table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`) end print(table.concat(results, " | ")) diff --git a/benches/mapped.luau b/benches/mapped.luau index 695b7f5..27ca6bd 100644 --- a/benches/mapped.luau +++ b/benches/mapped.luau @@ -3,56 +3,55 @@ local bench = require("./bench") local computed = Charm.computed local mapped = Charm.mapped +local setActiveSub = Charm.setActiveSub local signal = Charm.signal -type Key = number -type Value = number +type Entry = { number } local count = 512 -local initialState: { [Key]: Value } = table.create(count, 0) +local initialState: { Entry } = table.create(count) -local function expensiveOperation() - for _ = 1, 100 do - continue - end +for i = 1, count do + initialState[i] = table.create(64, i) end -local function simpleMapped( - source: () -> { [Key]: Value }, - mapper: (Value, Key) -> (Value, Key?) -): () -> { [Key]: Value } +local function mapper(value: Entry, key: number): (Entry, number) + return table.clone(value), key + 1 +end + +local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) -> (Entry, number)): () -> { Entry } return computed(function() local state = source() local nextState = {} - for keyIn, valueIn in state do - local valueOut, keyOut = mapper(valueIn, keyIn) - nextState[if keyOut == nil then keyIn else keyOut] = valueOut + for index, value in state do + local currentSub = setActiveSub(nil) + local success, valueOut, keyOut = pcall(mapper, value, index) + setActiveSub(currentSub) + + if success then + nextState[keyOut] = valueOut + end end - return nextState + return state end) end -local function updateRandomly(updateChance: number): ({ [Key]: Value }) -> { [Key]: Value } +local function updatePercentage(percent: number): ({ Entry }) -> { Entry } return function(state) local nextState = table.clone(state) - for key in state do - if math.random() < updateChance then - nextState[key] += 1 + for index, value in state do + if math.random() < percent then + nextState[index] = table.clone(value) end end return nextState end end -for _, updateChance in { 0.1, 0.5, 1 } do - local function mapper(value: Value, key: Key): (Value, Key?) - expensiveOperation() - return value + 1, key + 1 - end - - bench.run(`mapped {updateChance}`, function() +for _, percent in { 0.1, 0.5, 1 } do + bench.run(`mapped {percent}`, function() local src, setSrc = signal(initialState) local out = mapped(src, mapper) @@ -60,14 +59,14 @@ for _, updateChance in { 0.1, 0.5, 1 } do out() bench.profileend() - setSrc(updateRandomly(updateChance)) + setSrc(updatePercentage(percent)) bench.profilebegin("update") out() bench.profileend() end) - bench.run(`control {updateChance}`, function() + bench.run(`control {percent}`, function() local src, setSrc = signal(initialState) local out = simpleMapped(src, mapper) @@ -75,7 +74,7 @@ for _, updateChance in { 0.1, 0.5, 1 } do out() bench.profileend() - setSrc(updateRandomly(updateChance)) + setSrc(updatePercentage(percent)) bench.profilebegin("update") out() diff --git a/benches/multi.luau b/benches/multi.luau deleted file mode 100644 index 6c8235c..0000000 --- a/benches/multi.luau +++ /dev/null @@ -1,188 +0,0 @@ -local Charm = require("../packages/charm/src") -local bench = require("./bench") - -type Atom = Charm.Atom - -local signal = Charm.atom -local computed = Charm.computed -local effect = Charm.effect - -local function busy(): number - local a = 0 - for i = 1, 100 do - a += 1 - end - return a -end - -do - local head = signal(0) - local computed1 = computed(function() - return head() - end) - local computed2 = computed(function() - computed1() - return 0 - end) - local computed3 = computed(function() - busy() -- heavy computation - return computed2() + 1 - end) - local computed4 = computed(function() - return computed3() + 2 - end) - local computed5 = computed(function() - return computed4() + 3 - end) - effect(function() - computed5() - busy() -- heavy side effect - end) - - bench.run("avoidablePropagation", function() - head(1) - assert(computed5() == 6) - for i = 0, 999 do - head(i) - end - assert(computed5() == 6) - end) -end - -do - local head = signal(0) - local double = computed(function() - return head() * 2 - end) - local inverse = computed(function() - return -head() - end) - local current = computed(function() - local result = 0 - for i = 0, 19 do - result += if head() % 2 == 1 then double() else inverse() - end - return result - end) - - local callCounter = 0 - effect(function() - current() - callCounter += 1 - end) - - bench.run("unstable", function() - head(1) - assert(current() == 40) - callCounter = 0 - for i = 0, 99 do - head(i) - end - assert(callCounter == 100) - end) -end - -do - local head = signal(0) - local double = computed(function() - return head() * 2 - end) - local inverse = computed(function() - return -head() - end) - local current = computed(function() - local result = 0 - for i = 0, 19 do - result += if head() % 2 == 1 and i % 2 == 1 then double() else inverse() - end - return result - end) - - local callCounter = 0 - effect(function() - current() - callCounter += 1 - end) - - bench.run("unstable 50%", function() - head(1) - assert(current() == 10) - callCounter = 0 - for i = 0, 99 do - head(i) - end - assert(callCounter == 100) - end) -end - -do - local head = signal(0) - local last: () -> number = head - local callCounter = 0 - - for i = 0, 49 do - local current = computed(function() - return head() + i - end) - local current2 = computed(function() - return current() + 1 - end) - effect(function() - current2() - callCounter += 1 - end) - last = current2 - end - - bench.run("broadPropagation", function() - head(1) - callCounter = 0 - for i = 0, 49 do - head(i) - assert(last() == i + 50) - end - assert(callCounter == 50 * 50) - end) -end - -do - local heads = {} - for index = 0, 99 do - heads[index] = signal(0) - end - - local mux = computed(function() - local result = {} - for index = 0, 99 do - result[index] = heads[index]() - end - return result - end) - - local splited = {} - for index = 0, 99 do - local x = computed(function() - return mux()[index] - end) - splited[index] = computed(function() - return x() + 1 - end) - end - - for _, x in splited do - effect(function() - x() - end) - end - - bench.run("mux", function() - for i = 0, 9 do - heads[i](i) - assert(splited[i]() == i + 1) - end - for i = 0, 9 do - heads[i](i * 2) - assert(splited[i]() == i * 2 + 1) - end - end) -end diff --git a/benches/reactive.luau b/benches/reactive.luau index c272651..2e499ca 100644 --- a/benches/reactive.luau +++ b/benches/reactive.luau @@ -1,9 +1,8 @@ local Charm = require("../packages/charm/src") -local DeepCharm = require("../packages/deep-charm/src") local bench = require("./bench") local effect = Charm.effect -local reactive = DeepCharm.reactive +local reactive = Charm.reactive do local value = reactive({ a = 1 }) diff --git a/bin/build.sh b/bin/build.sh new file mode 100644 index 0000000..f680537 --- /dev/null +++ b/bin/build.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +mkdir build +rojo build build.project.json -o build/charm.rbxm diff --git a/bin/check.sh b/bin/check.sh new file mode 100644 index 0000000..091c4a4 --- /dev/null +++ b/bin/check.sh @@ -0,0 +1,19 @@ +curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.luau + +rojo sourcemap -o sourcemap.json + +luau-lsp analyze \ + --defs=bin/roblox.d.luau \ + --flag:LuauFixIndexerSubtypingOrdering=true \ + --flag:LuauInstantiateInSubtyping=true \ + --sourcemap=sourcemap.json \ + --ignore="**/node_modules/**" \ + packages test benches + +selene packages test benches + +stylua --check packages test benches + +pnpm eslint packages + +rm bin/roblox.d.luau diff --git a/build.project.json b/build.project.json index 53d32b4..8141b86 100644 --- a/build.project.json +++ b/build.project.json @@ -4,7 +4,6 @@ "$className": "Folder", "Charm": { "$path": "packages/charm/src" }, "CharmSync": { "$path": "packages/charm-sync/src" }, - "DeepCharm": { "$path": "packages/deep-charm/src" }, "ReactCharm": { "$path": "packages/react-charm/src" }, "VideCharm": { "$path": "packages/vide-charm/src" } } diff --git a/default.project.json b/default.project.json index 85d8f55..43a7d1a 100644 --- a/default.project.json +++ b/default.project.json @@ -19,25 +19,16 @@ }, "charm": { "$className": "Folder", - "src": { "$path": "packages/charm/src" }, - "test": { "$path": "packages/charm/test" } - }, - "deep-charm": { - "$className": "Folder", - "src": { "$path": "packages/deep-charm/src" }, - "test": { "$path": "packages/deep-charm/test" }, - "Charm": { "$path": "packages/deep-charm/Charm.luau" } + "src": { "$path": "packages/charm/src" } }, "charm-sync": { "$className": "Folder", "src": { "$path": "packages/charm-sync/src" }, - "test": { "$path": "packages/charm-sync/test" }, "Charm": { "$path": "packages/charm-sync/Charm.luau" } }, "react-charm": { "$className": "Folder", "src": { "$path": "packages/react-charm/src" }, - "test": { "$path": "packages/react-charm/test" }, "React": { "$path": "packages/react-charm/React.luau" }, "ReactRoblox": { "$path": "packages/react-charm/ReactRoblox.luau" }, "Charm": { "$path": "packages/react-charm/Charm.luau" } @@ -45,7 +36,6 @@ "vide-charm": { "$className": "Folder", "src": { "$path": "packages/vide-charm/src" }, - "test": { "$path": "packages/vide-charm/test" }, "Vide": { "$path": "packages/vide-charm/Vide.luau" }, "Charm": { "$path": "packages/vide-charm/Charm.luau" } } @@ -53,8 +43,8 @@ "@rbxts-js": { "$path": "node_modules/@rbxts-js" }, - "tests": { - "$path": "tests" + "test": { + "$path": "test" } } } diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index bfccfb4..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,20 +0,0 @@ -const eslint = require("@eslint/js"); -const { defineConfig, globalIgnores } = require("eslint/config"); -const prettierRecommended = require("eslint-plugin-prettier/recommended"); -const roblox = require("eslint-plugin-roblox-ts"); -const tseslint = require("typescript-eslint"); - -module.exports = defineConfig( - eslint.configs.recommended, - tseslint.configs.recommended, - roblox.configs.tsRecommendedCompat, - roblox.configs.recommended, - { - rules: { - "@typescript-eslint/no-explicit-any": "off", - "roblox-ts/no-any": "off", - }, - }, - globalIgnores(["out/**", "eslint.config.js"]), - prettierRecommended, -); diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000..21ece76 --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,20 @@ +import eslint from "@eslint/js"; +import { defineConfig, globalIgnores } from "eslint/config"; +import prettierRecommended from "eslint-plugin-prettier/recommended"; +import roblox from "eslint-plugin-roblox-ts"; +import tseslint from "typescript-eslint"; + +export default defineConfig( + eslint.configs.recommended, + tseslint.configs.recommended, + roblox.configs.tsRecommendedCompat, + roblox.configs.recommended, + { + rules: { + "@typescript-eslint/no-explicit-any": "off", + "roblox-ts/no-any": "off", + }, + }, + globalIgnores(["out/**"]), + prettierRecommended, +); diff --git a/package.json b/package.json index d0bf03d..b9b8739 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,30 @@ { "private": true, "scripts": { - "test": "lute run test", - "build": "lute run build", - "check": "lute run check", + "test": "lune run test/spec.server.luau", + "build": "sh bin/build.sh", + "check": "sh bin/check.sh", "publish:npm": "pnpm publish -r --access public", "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" }, - "packageManager": "pnpm@12.8.1", + "packageManager": "pnpm@10.22.0", "devDependencies": { - "@eslint/js": "^9.39.4", + "@eslint/js": "^9.36.0", "@rbxts/compiler-types": "3.0.0-types.0", - "@rbxts/react": "17.3.7-ts.1", - "@rbxts/react-roblox": "17.3.7-ts.1", - "@rbxts/types": "^1.0.913", - "@rbxts/vide": "^0.6.1", - "@typescript-eslint/eslint-plugin": "^8.58.0", - "@typescript-eslint/parser": "^8.58.0", - "eslint": "^9.39.4", + "@rbxts/react": "17.3.5-alpha.1", + "@rbxts/react-roblox": "17.3.5-alpha.1", + "@rbxts/types": "^1.0.883", + "@rbxts/vide": "^0.5.7", + "@typescript-eslint/eslint-plugin": "^8.44.1", + "@typescript-eslint/parser": "^8.44.1", + "eslint": "^9.36.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.5", - "eslint-plugin-roblox-ts": "^1.4.0", - "prettier": "^3.8.1", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-roblox-ts": "^1.2.1", + "jiti": "^2.6.0", + "prettier": "^3.6.2", "roblox-ts": "^3.0.0", - "typescript": "^5.9.3", - "typescript-eslint": "^8.58.0" + "typescript": "^5.9.2", + "typescript-eslint": "^8.44.1" } } diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau index 480f2d5..a589bde 100644 --- a/packages/charm-sync/Charm.luau +++ b/packages/charm-sync/Charm.luau @@ -4,6 +4,7 @@ end local Charm = require(script.Parent.Parent.charm.src) -export type Atom = Charm.Atom +export type Atom = Charm.Atom +export type ReactiveProxy = Charm.ReactiveProxy return Charm diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json index f2c84a3..9c494b2 100644 --- a/packages/charm-sync/package.json +++ b/packages/charm-sync/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm-sync", - "version": "0.4.1", + "version": "0.4.0-rc.5", "description": "Simple server-to-client sync for Charm", "license": "MIT", "main": "src/init.luau", @@ -11,17 +11,17 @@ "!wally.toml", "!wally.lock", "!default.project.json", - "!test" + "!**/__tests__" ], "repository": { "type": "git", "url": "git+https://github.com/littensy/charm.git" }, "scripts": { - "prepare": "cp ../../LICENSE . && cp ../../README.md .", + "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "publish:wally": "wally publish" }, "dependencies": { - "@rbxts/charm": "workspace:^" + "@rbxts/charm": "workspace:*" } } diff --git a/packages/charm-sync/test/client.test.luau b/packages/charm-sync/src/__tests__/client.spec.luau similarity index 92% rename from packages/charm-sync/test/client.test.luau rename to packages/charm-sync/src/__tests__/client.spec.luau index 20c41a8..10164a2 100644 --- a/packages/charm-sync/test/client.test.luau +++ b/packages/charm-sync/src/__tests__/client.spec.luau @@ -1,9 +1,11 @@ -local Charm = require("../Charm") -local DeepCharm = require("../../deep-charm/src") -local client = require("../src/client") -local test = require("../../../tests/test") +local Charm = require("../../Charm") +local client = require("../client") +local suite = require("../../../../test/suite") -test.afterEach(function() +local test = suite.test +local afterEach = suite.afterEach + +afterEach(function() client.removeAllSignals() end) @@ -39,7 +41,7 @@ test("should merge patch", function() end) test("should merge patch (reactive)", function() - local a = DeepCharm.reactive({ b = 1, c = 2 }) + local a = Charm.reactive({ b = 1, c = 2 }) local runs = 0 Charm.effect(function() diff --git a/packages/charm-sync/test/e2e.test.luau b/packages/charm-sync/src/__tests__/e2e.spec.luau similarity index 83% rename from packages/charm-sync/test/e2e.test.luau rename to packages/charm-sync/src/__tests__/e2e.spec.luau index 62ca4a7..d8da3aa 100644 --- a/packages/charm-sync/test/e2e.test.luau +++ b/packages/charm-sync/src/__tests__/e2e.spec.luau @@ -1,11 +1,13 @@ -local Charm = require("../Charm") -local DeepCharm = require("../../deep-charm/src") -local client = require("../src/client") -local config = require("../src/config") -local server = require("../src/server") -local test = require("../../../tests/test") +local Charm = require("../../Charm") +local client = require("../client") +local config = require("../config") +local server = require("../server") +local suite = require("../../../../test/suite") -test.afterEach(function() +local afterEach = suite.afterEach +local test = suite.test + +afterEach(function() server.reset() client.removeAllSignals() config.preserveHistory = false @@ -14,9 +16,9 @@ end) test("should replicate server state to client", function() local target = {} local serverA = Charm.atom({ count = 0 }) - local serverB = DeepCharm.reactive({ count = 0 }) + local serverB = Charm.reactive({ count = 0 }) local clientA = Charm.atom({ count = -1 }) - local clientB = DeepCharm.reactive({ count = -1 }) + local clientB = Charm.reactive({ count = -1 }) client.addSignals({ a = clientA, b = clientB }) server.addSignalsToClient(target, { a = serverA, b = serverB }) @@ -51,9 +53,9 @@ end) test("should replicate historical server state to client", function() local target = {} local serverA = Charm.atom({ count = 0 }) - local serverB = DeepCharm.reactive({ count = 0 }) + local serverB = Charm.reactive({ count = 0 }) local clientA = Charm.atom({ count = -1 }) - local clientB = DeepCharm.reactive({ count = -1 }) + local clientB = Charm.reactive({ count = -1 }) config.preserveHistory = true diff --git a/packages/charm-sync/test/patch.test.luau b/packages/charm-sync/src/__tests__/patch.spec.luau similarity index 94% rename from packages/charm-sync/test/patch.test.luau rename to packages/charm-sync/src/__tests__/patch.spec.luau index 076544e..7ce89c2 100644 --- a/packages/charm-sync/test/patch.test.luau +++ b/packages/charm-sync/src/__tests__/patch.spec.luau @@ -1,8 +1,11 @@ -local config = require("../src/config") -local patch = require("../src/patch") -local test = require("../../../tests/test") +local config = require("../config") +local patch = require("../patch") +local suite = require("../../../../test/suite") -local function mockArraySerdes(object: { [any]: any }): { [any]: any } +local test = suite.test +local afterEach = suite.afterEach + +local function mockArraySerdes(object: {}): {} if object[1] then local copy = {} for index = 1, #object do @@ -20,7 +23,7 @@ local function mockArraySerdes(object: { [any]: any }): { [any]: any } end end -test.afterEach(function() +afterEach(function() config.fixArrays = true config.validatePatches = true end) diff --git a/packages/charm-sync/test/server.test.luau b/packages/charm-sync/src/__tests__/server.spec.luau similarity index 82% rename from packages/charm-sync/test/server.test.luau rename to packages/charm-sync/src/__tests__/server.spec.luau index d24970c..034fc54 100644 --- a/packages/charm-sync/test/server.test.luau +++ b/packages/charm-sync/src/__tests__/server.spec.luau @@ -1,12 +1,15 @@ -local Charm = require("../Charm") -local DeepCharm = require("../../deep-charm/src") -local config = require("../src/config") -local patch = require("../src/patch") -local server = require("../src/server") -local test = require("../../../tests/test") -local types = require("../src/types") +local Charm = require("../../Charm") +local config = require("../config") +local patch = require("../patch") +local server = require("../server") +local suite = require("../../../../test/suite") -test.afterEach(function() +local afterEach = suite.afterEach +local beforeEach = suite.beforeEach +local describe = suite.describe +local test = suite.test + +afterEach(function() server.reset() end) @@ -15,7 +18,7 @@ test("should replicate state changes", function() local a = Charm.atom(0) local b = Charm.atom(0) local c = Charm.atom(0) - local target: unknown, payloads: { types.SyncPayload } + local target, payloads server.addSignalsToClient(client, { a = a, b = b, c = c }) server.connect(function(...) @@ -42,7 +45,7 @@ test("should replicate partial state patches", function() local client = {} local a = Charm.atom({ x = 0, y = 0 }) local b = Charm.atom(0 :: number?) - local target: unknown, payloads: { types.SyncPayload } + local target, payloads server.addSignalsToClient(client, { a = a, b = b }) server.connect(function(...) @@ -68,8 +71,8 @@ end) test("should replicate partial state patches (reactive)", function() local client = {} - local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? }) - local target: unknown, payloads: { types.SyncPayload } + local a = Charm.reactive({ x = { y = 0 }, z = 0 :: number? }) + local target, payloads server.addSignalsToClient(client, { a = a }) server.connect(function(...) @@ -101,7 +104,7 @@ end) test("should handle nested proxies", function() local client = {} - local src = DeepCharm.reactive({ (DeepCharm.reactive({})) }) + local src = Charm.reactive({ (Charm.reactive({})) }) local payloads: {}? = nil server.addSignalsToClient(client, { src = src }) @@ -119,7 +122,7 @@ test("should init new clients", function() local clientA = {} local clientB = {} local a = Charm.atom(0) - local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } + local payloadsA, payloadsB server.addSignalsToClient(clientA, { a = a }) server.connect(function(target, payloads) @@ -148,7 +151,7 @@ test("should init new atoms", function() local client = {} local a = Charm.atom(0) local b = Charm.atom(0) - local target: unknown, payloads: { types.SyncPayload } + local target, payloads server.addSignalsToClient(client, { a = a }) server.connect(function(...) @@ -173,7 +176,7 @@ test("should only replicate to synced clients", function() local clientB = {} local a = Charm.atom(0) local b = Charm.atom(0) - local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } + local payloadsA, payloadsB server.addSignalsToClient(clientA, { a = a }) server.addSignalsToClient(clientB, { b = b }) @@ -215,7 +218,7 @@ end) test("should serialize state", function() local client = {} local a = Charm.atom({ 1, 2, 3, 4, 5 }) - local payloads: { types.SyncPayload } + local payloads server.addSignalsToClient(client, { a = a }) server.connect(function(_, p) @@ -232,7 +235,7 @@ end) test("should not send unchanged state", function() local client = {} local a = Charm.atom(0) - local payloads: { types.SyncPayload } + local payloads server.addSignalsToClient(client, { a = a }) server.connect(function(_, p) @@ -249,12 +252,12 @@ test("should not send unchanged state", function() assert(#payloads == 0, "Expected no payloads to be sent for unchanged state") end) -test.describe("historical", function() - test.beforeEach(function() +describe("historical", function() + beforeEach(function() config.preserveHistory = true end) - test.afterEach(function() + afterEach(function() config.preserveHistory = false end) @@ -263,7 +266,7 @@ test.describe("historical", function() local a = Charm.atom(0) local b = Charm.atom(0) local c = Charm.atom(0) - local target: unknown, payloads: { types.SyncPayload } + local target, payloads server.addSignalsToClient(client, { a = a, b = b, c = c }) server.connect(function(...) @@ -290,10 +293,10 @@ test.describe("historical", function() test("should replicate granular state changes (reactive)", function() local client = {} - local a = DeepCharm.reactive({ value = 0 }) - local b = DeepCharm.reactive({ value = 0 }) - local c = DeepCharm.reactive({ value = 0 }) - local target: unknown, payloads: { types.SyncPayload } + local a = Charm.reactive({ value = 0 }) + local b = Charm.reactive({ value = 0 }) + local c = Charm.reactive({ value = 0 }) + local target, payloads server.addSignalsToClient(client, { a = a, b = b, c = c }) server.connect(function(...) @@ -322,7 +325,7 @@ test.describe("historical", function() local clientA = {} local clientB = {} local a = Charm.atom(0) - local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload } + local payloadsA, payloadsB server.addSignalsToClient(clientA, { a = a }) server.connect(function(target, payloads) @@ -358,7 +361,7 @@ test.describe("historical", function() local client = {} local a = Charm.atom(0) local b = Charm.atom(0) - local payloads: { types.SyncPayload } + local payloads server.addSignalsToClient(client, { a = a }) server.connect(function(_, ...) @@ -387,7 +390,7 @@ test.describe("historical", function() test("should not send unchanged state", function() local client = {} local a = Charm.atom(0) - local payloads: { types.SyncPayload } + local payloads server.addSignalsToClient(client, { a = a }) server.connect(function(_, p) @@ -403,54 +406,6 @@ test.describe("historical", function() server.flush() assert(#payloads == 0, "Expected no payloads to be sent for unchanged state") end) - - test("should maintain patch list order for stale update", function() - local a = Charm.atom({ x = 1 }) - local b = Charm.atom({ y = 1 }) - local payloads - - server.connect(function(_client, _payloads) - payloads = _payloads - end) - - server.addSignalsToClient("client", { a = a, b = b }) - server.flush() - - payloads = nil - a({ x = 1 }) -- new table, same contents: diff is nil - b({ y = 2 }) - server.flush() - assert(payloads, "Expected payloads to be sent") - assert(payloads[1].type == "patch", "Expected payload to be patch") - assert(payloads[1].data.b.y == 2, "Expected payload to contain atom 'b'") - end) -end) - -test("addSignalsToClient should ignore tracking context", function() - local counter = Charm.atom(0) - local payloads: { any }? - - server.connect(function(_client, _payloads) - payloads = _payloads - end) - - local dispose = Charm.effectScope(function() - server.addSignalsToClient("PLAYER_ID", { counter = counter }) - end) - - server.flush() - assert(payloads, "Expected payloads to be sent") - assert(payloads[1].type == "init", "Expected first payload to be init") - assert(payloads[1].data.counter == 0, "Expected init payload to contain atom 'counter'") - - dispose() - - payloads = nil - counter(1) - server.flush() - assert(payloads, "Expected payloads to be sent") - assert(payloads[1].type == "patch", "Expected first payload to be patch") - assert(payloads[1].data.counter == 1, "Expected patch payload to contain atom 'counter'") end) return {} diff --git a/packages/charm-sync/src/__tests__/spec.luau b/packages/charm-sync/src/__tests__/spec.luau new file mode 100644 index 0000000..8d07f04 --- /dev/null +++ b/packages/charm-sync/src/__tests__/spec.luau @@ -0,0 +1,23 @@ +local suite = require("../../../../test/suite") + +suite.describe("client", function() + require("./client.spec") +end) + +suite.describe("e2e", function() + require("./e2e.spec") +end) + +suite.describe("utils", function() + require("./utils.spec") +end) + +suite.describe("patch", function() + require("./patch.spec") +end) + +suite.describe("server", function() + require("./server.spec") +end) + +return {} diff --git a/packages/charm-sync/test/utils.test.luau b/packages/charm-sync/src/__tests__/utils.spec.luau similarity index 65% rename from packages/charm-sync/test/utils.test.luau rename to packages/charm-sync/src/__tests__/utils.spec.luau index 1d50f55..47d2e39 100644 --- a/packages/charm-sync/test/utils.test.luau +++ b/packages/charm-sync/src/__tests__/utils.spec.luau @@ -1,15 +1,16 @@ -local Charm = require("../Charm") -local test = require("../../../tests/test") -local utils = require("../src/utils") +local Charm = require("../../Charm") +local suite = require("../../../../test/suite") +local utils = require("../utils") + +local test = suite.test test("should flatten functions", function() local input = { a = Charm.atom(0), b = { c = Charm.atom(0) }, - d = { e = { f = utils.signalToAtom<>(Charm.signal(0)) } }, + d = { e = { f = utils.signalToAtom(Charm.signal(0)) } }, } - - local result = utils.flatten<<{ [string]: Charm.Atom }>>(input) + local result = utils.flatten(input) assert(result.a == input.a, "should flatten the first level") assert(result["b/c"] == input.b.c, "should flatten the second level") diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 973a983..e02de4b 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -2,8 +2,8 @@ local Charm = require("../Charm") local patch = require("./patch") local types = require("./types") +type ReactiveProxy = Charm.ReactiveProxy type SyncPayload = types.SyncPayload -type ReactiveProxy = types.ReactiveProxy local trackedSignals: { [string]: (any) -> any } = {} local trackedReactives: { [string]: ReactiveProxy } = {} @@ -64,8 +64,6 @@ local function applyInitPayload(data: { [string]: any }) trackedSignals[key](value) elseif trackedReactives[key] then shallowMergeReactive(trackedReactives[key], value) - else - warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`) end end end @@ -82,8 +80,6 @@ local function applyPatchPayload(data: { [string]: any }) elseif trackedReactive then patch.applyMutable(trackedReactive.__target, diffs) Charm.trigger(trackedReactive.__track) - else - warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`) end end end @@ -94,7 +90,7 @@ end @param payloads The state updates received from the server. ]] -local function applyPatches(payloads: { SyncPayload }) +local function patch(payloads: { SyncPayload }) for _, payload in payloads do if payload.type == "init" then Charm.batch(applyInitPayload, payload.data) @@ -108,5 +104,5 @@ return { addSignals = addSignals, removeSignals = removeSignals, removeAllSignals = removeAllSignals, - patch = applyPatches, + patch = patch, } diff --git a/packages/charm-sync/src/config.luau b/packages/charm-sync/src/config.luau index c3ce27c..a101f05 100644 --- a/packages/charm-sync/src/config.luau +++ b/packages/charm-sync/src/config.luau @@ -9,22 +9,19 @@ return { Whether the history of state changes since the client's last update should be preserved. This is useful for values that change multiple times per frame, where each individual change is important. Defaults - to `false` for performance reasons. + to `false`. ]] preserveHistory = false, --[[ - When `true`, Charm will modify sparse arrays to prevent data loss from - remote event limitations. When an unsafe array is detected, numeric - indices will be converted to string keys so that the array can be sent - safely and then converted back to an array on the receiving end. - + When `true`, Charm will apply validation and serialize unsafe arrays + to address remote event argument limitations. Defaults to `true`. This option should be disabled if your network library uses a custom serialization method (i.e. Zap, ByteNet) to prevent interference. ]] fixArrays = true, --[[ When `true`, synced state containing unsafe sparse arrays or mixed - tables will emit a warning. Only checked in strict mode and if + tables will throw an error. Only checked in strict mode and if `fixArrays` is enabled. Defaults to `true`. ]] validatePatches = true, diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index bb40eba..6519951 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -78,17 +78,14 @@ export const config: { * Whether the history of state changes since the client's last update * should be preserved. This is useful for values that change multiple * times per frame, where each individual change is important. Defaults - * to `false` for performance reasons. + * to `false`. * * @server */ preserveHistory: boolean; /** - * When `true`, Charm will modify sparse arrays to prevent data loss from - * remote event limitations. When an unsafe array is detected, numeric - * indices will be converted to string keys so that the array can be sent - * safely and then converted back to an array on the receiving end. - * + * When `true`, Charm will apply validation and serialize unsafe arrays + * to address remote event argument limitations. Defaults to `true`. * This option should be disabled if your network library uses a custom * serialization method (i.e. Zap, ByteNet) to prevent interference. * @@ -97,7 +94,7 @@ export const config: { fixArrays: boolean; /** * When `true`, synced state containing unsafe sparse arrays or mixed - * tables will emit a warning. Only checked in strict mode and if + * tables will throw an error. Only checked in strict mode and if * `fixArrays` is enabled. Defaults to `true`. * * @server @@ -245,7 +242,7 @@ export namespace patch { * @param statePatch The patches to apply. * @returns The new state with the patch applied. */ - export function applyImmutable( + export function apply( currentState: State, statePatch: SyncPatch, ): State; diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index d6b940f..b9a3be1 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -39,7 +39,7 @@ end long as the target array is not sparse. This function ensures that the diff function produces a patch that can be correctly applied. ]] -local function stringifySparseArray(object: { [any]: any }): { [string | number]: any } +local function stringifySparseArray(object: {}): { [string | number]: any } local maxn = table.maxn(object) if maxn == 0 or maxn == #object then diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 6485e18..07ce4ba 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -4,6 +4,7 @@ local patch = require("./patch") local types = require("./types") local utils = require("./utils") +type ReactiveProxy = Charm.ReactiveProxy type SyncPayload = types.SyncPayload type SyncStatus = number @@ -34,22 +35,9 @@ local clients: { [any]: ClientKeys } = {} local connection: RBXScriptConnection? local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())? ---[[ - Manually converts a reactive proxy to its raw representation to potentially - support custom implementations of reactive proxies. -]] -local function toRaw(value: T): T - local proxy = value :: types.ReactiveProxy - if type(proxy) == "table" and type(proxy.__target) == "table" and type(proxy.__track) == "function" then - proxy.__track() - return toRaw(proxy.__target) - end - return value -end - local function trackSignal(key: string, getter: () -> T, identifier: unknown) if trackedStates[key] then - -- Warn against name collisions with conflicting signals or proxies + -- Ignore duplicates, but warn against name collisions if trackedStates[key].identifier ~= identifier then warn("[Charm] Attempted to sync different states with the same key:", key) end @@ -58,12 +46,12 @@ local function trackSignal(key: string, getter: () -> T, identifier: unknown) local trackedState: TrackedState local initialValue = getter() - local rawInitialValue = toRaw(initialValue) + local rawInitialValue = Charm.toRaw(initialValue) local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue - local dispose = Charm.untracked(Charm.effect, function() + local dispose = Charm.effect(function() local newValue = getter() - local rawValue = toRaw(newValue) + local rawValue = Charm.toRaw(newValue) local isReactive = newValue ~= rawValue if not trackedState then @@ -125,8 +113,8 @@ local function diffGlobalUpdateBuffer(): { any } local patchCount = #globalUpdates local patches = table.create(patchCount) - for index, update in globalUpdates do - patches[index] = patch.diff(update.oldValue, update.newValue) + for _, update in globalUpdates do + table.insert(patches, patch.diff(update.oldValue, update.newValue)) end return patches @@ -146,7 +134,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { for index, update in globalUpdates do local patchData = globalPatches[index] - if patchData == nil then + if not patchData then continue end @@ -154,17 +142,15 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { local status = clientKeys[key] if status == LISTENING_FOR_CHANGES then - local payload: SyncPayload = { + table.insert(payloads, { type = "patch", data = { [key] = patchData }, - } - table.insert(payloads, payload) + }) elseif status == PENDING_INITIAL_STATE then - local payload: SyncPayload = { + table.insert(payloads, { type = "init", data = { [key] = update.newValue }, - } - table.insert(payloads, payload) + }) clientKeys[key] = LISTENING_FOR_CHANGES end end @@ -231,45 +217,16 @@ local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any } end if next(initData) ~= nil then - local payload: SyncPayload = { - type = "init", - data = initData, - } - table.insert(payloads, payload) + table.insert(payloads, { type = "init", data = initData }) end if next(patchData) ~= nil then - local payload: SyncPayload = { - type = "patch", - data = patchData, - } - table.insert(payloads, payload) + table.insert(payloads, { type = "patch", data = patchData }) end return payloads end -local function startInterval(callback: () -> ()) - if not game then - return - end - - if connection then - connection:Disconnect() - end - - local timestamp = os.clock() - - connection = game:GetService("RunService").Heartbeat:Connect(function() - if config.interval >= 0 and os.clock() >= timestamp + config.interval then - timestamp = os.clock() - debug.profilebegin("CharmSync") - callback() - debug.profileend() - end - end) -end - --[[ Subscribes a client to the given signals. When an update occurs, the client will receive a partial state patch to merge into their local state. May be @@ -313,7 +270,6 @@ end ]] local function removeClient(client: any) clients[client] = nil - purgeUntracked() end --[[ @@ -336,8 +292,6 @@ local function removeSignalsFromClient(client: any, ...: string) if next(clientKeys) == nil then removeClient(client) - else - purgeUntracked() end end @@ -368,6 +322,7 @@ local function flush() end end + purgeUntracked() table.clear(globalUpdates) end @@ -379,8 +334,32 @@ end @param onSync Called when sending patches to a client. ]] local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ()) + if connection then + connection:Disconnect() + end + syncCallback = onSync - startInterval(flush) + + if not game then + return + end + + local timer = 0 + + connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime) + if config.interval < 0 then + return + end + + timer += deltaTime + + if timer >= config.interval then + timer = 0 + debug.profilebegin("CharmSync") + flush() + debug.profileend() + end + end) end --[[ diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau index 664ee61..20af1f3 100644 --- a/packages/charm-sync/src/types.luau +++ b/packages/charm-sync/src/types.luau @@ -18,13 +18,4 @@ export type SyncPayload = ]] export type None = { __none: "__none" } -export type ReactiveProxy = typeof(setmetatable( - {} :: { - __track: () -> (), - __target: T, - __signals: { [unknown]: () -> unknown }, - }, - {} :: { __index: T } -)) - return {} diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau index 4a49871..a4dae84 100644 --- a/packages/charm-sync/src/utils.luau +++ b/packages/charm-sync/src/utils.luau @@ -2,6 +2,10 @@ local Charm = require("../Charm") type Atom = Charm.Atom +type NestedTable = { + [any]: NestedTable | any, +} + --[[ Flattens a nested table into a single table with slash-separated keys. Useful for recursively collecting atoms returned by modules. @@ -9,15 +13,15 @@ type Atom = Charm.Atom @param input A nested table. @return A flattened table with slash-separated keys. ]] -local function flatten(input: { [any]: any }): Flattened +local function flatten(input: {}): Flattened local result = {} - local function visit(node: { [any]: any }, path: string) + local function visit(node: {}, path: string) for key, value in node do local location = if path == "" then tostring(key) else path .. "/" .. tostring(key) if type(value) == "table" then - visit(value, location) + visit(value :: {}, location) else result[location] = value end diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau index dfba716..ac21161 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -1,11 +1,11 @@ -local function isTableUnsafe(object: { [any]: any }) - local firstKey = next(object) +local function isTableUnsafe(object: {}) + local key = next(object) - if firstKey == nil then + if key == nil then return false end - local keyType = type(firstKey) + local keyType = type(key) local maxKey = 0 -- Keys must all be the same type @@ -31,11 +31,13 @@ end with remote argument limitations, such as sparse arrays or mixed tables. @param value The table value. + @param key The key of the value. ]] -local function validate(value: { [any]: any }) +local function validate(value: {}) if isTableUnsafe(value) then warn( - "Cannot sync tables unsupported by remote events! This can be for the following reasons:\n" + `Cannot sync tables unsupported by remote events! See the table below for more info.\n\n` + .. "This can be for the following reasons:\n" .. "1. The table is a sparse array with non-sequential indices\n" .. "2. The table is a mixed table (not all keys have the same type)\n\n" .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n", diff --git a/packages/charm-sync/test/init.luau b/packages/charm-sync/test/init.luau deleted file mode 100644 index 111dc1d..0000000 --- a/packages/charm-sync/test/init.luau +++ /dev/null @@ -1,23 +0,0 @@ -local test = require("../../tests/test") - -test.describe("client", function() - require("@self/client.test") -end) - -test.describe("e2e", function() - require("@self/e2e.test") -end) - -test.describe("utils", function() - require("@self/utils.test") -end) - -test.describe("patch", function() - require("@self/patch.test") -end) - -test.describe("server", function() - require("@self/server.test") -end) - -return {} diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml index 589e7ed..80e3061 100644 --- a/packages/charm-sync/wally.toml +++ b/packages/charm-sync/wally.toml @@ -1,15 +1,15 @@ [package] name = "littensy/charm-sync" -version = "0.4.1" +version = "0.4.0-rc.5" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ "node_modules", - "test", + "src/__tests__", "src/index.d.ts", "package.json", "Charm.luau", ] [dependencies] -Charm = "littensy/charm@^0.11.0" +Charm = "littensy/charm@^0.11.0-rc.3" diff --git a/packages/charm/package.json b/packages/charm/package.json index 7ef2e29..56cf80a 100644 --- a/packages/charm/package.json +++ b/packages/charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/charm", - "version": "0.11.1", + "version": "0.11.0-rc.3", "description": "Atomic state management based on alien-signals", "license": "MIT", "main": "src/init.luau", @@ -11,14 +11,14 @@ "!wally.toml", "!wally.lock", "!default.project.json", - "!test" + "!**/__tests__" ], "repository": { "type": "git", "url": "git+https://github.com/littensy/charm.git" }, "scripts": { - "prepare": "cp ../../LICENSE . && cp ../../README.md .", + "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "publish:wally": "wally publish" } } diff --git a/packages/charm/src/__tests__/computed.spec.luau b/packages/charm/src/__tests__/computed.spec.luau new file mode 100644 index 0000000..3dd7284 --- /dev/null +++ b/packages/charm/src/__tests__/computed.spec.luau @@ -0,0 +1,106 @@ +local Charm = require("../") +local suite = require("../../../../test/suite") + +local computed = Charm.computed +local effect = Charm.effect +local signal = Charm.signal +local test = suite.test + +test("should correctly propagate changes through computed core", function() + local src, setSrc = signal(0) + local c1 = computed(function() + return src() % 2 + end) + local c2 = computed(function() + return c1() + end) + local c3 = computed(function() + return c2() + end) + + c3() + setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty + c2() -- c1 -> none, c2 -> none + setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty + + assert(c3() == 1, "bad final value") +end) + +test("should propagate updated source value through chained computations", function() + local src, setSrc = signal(0) + local a = computed(function() + return src() + end) + local b = computed(function() + return a() % 2 + end) + local c = computed(function() + return src() + end) + local d = computed(function() + return b() + c() + end) + + assert(d() == 0, "bad initial value") + setSrc(2) + assert(d() == 2, "bad updated value") +end) + +test("should handle flags are indirectly updated during checkDirty", function() + local a, setA = signal(false) + local b = computed(function() + return a() + end) + local c = computed(function() + b() + return 0 + end) + local d = computed(function() + c() + return b() + end) + + assert(d() == false, "bad initial value") + setA(true) + assert(d() == true, "bad updated value") +end) + +test("should not update if the signal value is reverted", function() + local times = 0 + + local src, setSrc = signal(0) + local c1 = computed(function() + times += 1 + return src() + end) + c1() + assert(times == 1, "computed did not run") + setSrc(1) + setSrc(0) + c1() + assert(times == 1, "computed ran when it should not") +end) + +test("should receive previous value", function() + local calls = {} + local src, setSrc = signal(0) + local c1 = computed(function(current): number + local incoming = src() + return if current == incoming or incoming % 2 == 1 then current or incoming else incoming + end) + + effect(function() + table.insert(calls, c1()) + end) + + setSrc(2) + setSrc(3) + assert(c1() == 2, "should skip odd value") + + setSrc(4) + setSrc(5) + assert(c1() == 4, "should skip odd value") + assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`) +end) + +return {} diff --git a/packages/charm/test/effect.test.luau b/packages/charm/src/__tests__/effect.spec.luau similarity index 55% rename from packages/charm/test/effect.test.luau rename to packages/charm/src/__tests__/effect.spec.luau index 17cd0a0..56fe9c1 100644 --- a/packages/charm/test/effect.test.luau +++ b/packages/charm/src/__tests__/effect.spec.luau @@ -1,14 +1,16 @@ -local Charm = require("../src") -local test = require("../../../tests/test") +local Charm = require("../") +local suite = require("../../../../test/suite") local computed = Charm.computed local effect = Charm.effect local effectScope = Charm.effectScope local endBatch = Charm.endBatch local onCleanup = Charm.onCleanup +local recursive = Charm.recursive local setActiveSub = Charm.setActiveSub local signal = Charm.signal local startBatch = Charm.startBatch +local test = suite.test test("should clear subscriptions when untracked by all subscribers", function() local bRunTimes = 0 @@ -239,11 +241,8 @@ test("should handle side effect with inner effects", function() table.clear(order) setB(1) setA(1) + assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`) end) - - -- DEVIATION: This assertion was moved outside of the effect so that it - -- runs after the batch updates have been processed - assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`) end) test("should handle flags are indirectly updated during checkDirty", function() @@ -271,52 +270,33 @@ test("should handle flags are indirectly updated during checkDirty", function() assert(triggers == 2, "did not update indirectly") end) -test("should not execute skipped effects from previous failed flush when updating unrelated signal", function() - local a, setA = signal(0) - local b, setB = signal(0) - local c, setC = signal(0) - local d = computed(function() - c() - return 0 - end) +test("should handle effect recursion for the first execution", function() + local src1, setSrc1 = signal(0) + local src2, setSrc2 = signal(0) - local effect3Executed = false + local triggers1 = 0 + local triggers2 = 0 effect(function() - a() + triggers1 += 1 + setSrc1(math.min(src1() + 1, 5)) end) effect(function() - if a() == 2 then - error("Error in effect 2") - end - end) - effect(function() - a() - d() - effect3Executed = true - end) - effect(function() - b() + triggers2 += 1 + setSrc2(math.min(src2() + 1, 5)) + src2() 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") + assert(triggers1 == 1, `bad triggers1: {triggers1}`) + assert(triggers2 == 1, `bad triggers2: {triggers2}`) end) -test("should allow finite recursion", function() +test("should opt in to recursion", function() local src, setSrc = signal(0) local order = {} effect(function() + recursive() table.insert(order, src()) setSrc(math.min(src() + 1, 5)) end) @@ -408,186 +388,4 @@ test("should run cleanups outside any evaluation context", function() assert(calls == 0, "should not run after b update") end) -test("should not retrigger the effect if the cleanup modifies one of the dependencies", function() - local a, setA = signal(0) - local calls = {} - - 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() - 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) - 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/src/__tests__/effectScope.spec.luau similarity index 73% rename from packages/charm/test/effectScope.test.luau rename to packages/charm/src/__tests__/effectScope.spec.luau index 964b7d5..c484151 100644 --- a/packages/charm/test/effectScope.test.luau +++ b/packages/charm/src/__tests__/effectScope.spec.luau @@ -1,10 +1,11 @@ -local Charm = require("../src") -local test = require("../../../tests/test") +local Charm = require("../") +local suite = require("../../../../test/suite") local effect = Charm.effect local effectScope = Charm.effectScope local signal = Charm.signal local onCleanup = Charm.onCleanup +local test = suite.test test("should not trigger after stop", function() local count, setCount = signal(1) @@ -31,8 +32,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 +51,8 @@ end) test("should run indirect effect cleanup", function() local cleanups = 0 - local a, setA = signal(false) + local a, setA = signal(false) local stopScope = effectScope(function() effect(function() a() @@ -70,26 +71,13 @@ test("should run indirect effect cleanup", function() assert(cleanups == 2, "should not cleanup after update") end) -test("should dispose inner effects if created in an effect", function() - local source, setSource = signal(1) - local triggers = 0 - local dispose: () -> () - - effect(function() - dispose = effectScope(function() - effect(function() - source() - triggers += 1 - end) +test("should disallow yielding", function() + local success = pcall(function() + effectScope(function() + (if task then task.wait else coroutine.yield :: never)() end) - assert(triggers == 1, "did not run initially") end) - - setSource(2) - assert(triggers == 2, "did not run after update") - dispose() - setSource(3) - assert(triggers == 2, "ran after dispose") + assert(not success, "should error on yield") end) return {} diff --git a/packages/charm/test/gc.test.luau b/packages/charm/src/__tests__/gc.spec.luau similarity index 50% rename from packages/charm/test/gc.test.luau rename to packages/charm/src/__tests__/gc.spec.luau index aed0af1..d74196e 100644 --- a/packages/charm/test/gc.test.luau +++ b/packages/charm/src/__tests__/gc.spec.luau @@ -1,10 +1,14 @@ -local Charm = require("../src") -local test = require("../../../tests/test") +local Charm = require("../") +local suite = require("../../../../test/suite") + +type ReactiveProxy = Charm.ReactiveProxy local signal = Charm.signal local computed = Charm.computed local effect = Charm.effect local effectScope = Charm.effectScope +local reactive = Charm.reactive +local test = suite.test local function weakRef(value: T): { value: T? } return setmetatable({ value = value }, { __mode = "v" }) :: any @@ -26,7 +30,7 @@ end immediately closing it, causing alien-signals to unlink the computed value from its dependencies and allow it to be garbage collected. ]] -local function unlink(getter: () -> unknown) +local function unlink(getter: () -> ()) effectScope(function() getter() end)() @@ -62,4 +66,62 @@ test("should not release effect for atom", function() assert(effectCalls == 2, "should call effect again") end) +test("should release inner reactive proxy", function() + local valueRef = weakRef({}) + local src: any = reactive({ value = valueRef.value }) + + local dispose = effect(function() + local _ = src.value -- create proxy + end) + src.value = nil :: any -- release proxy + dispose() + + gc() + assert(src.__signals.value == nil, "should release signals") + assert(valueRef.value == nil, "should release inner proxy") +end) + +test("should release reactive property deps", function() + local src: any = reactive({ foo = 1 }) + + local c = computed(function() + return src.foo + end) + + c() + assert(src.__signals.foo ~= nil, "should have deps for foo") + + c = nil :: any + gc() + assert(src.__signals.foo == nil, "should release deps for foo") +end) + +test("should not release effect for reactive", function() + local src = reactive({ foo = 1 }) + local calls = 0 + + effect(function() + calls += 1 + local _ = src.foo + end) + assert(calls == 1, "should call effect once") + + gc() + src.foo += 1 + assert(calls == 2, "should call effect again") +end) + +test("should not release tracked reactive", function() + local src = Charm.reactive({ users = {} :: { [string]: {}? } }) + local user + + effect(function() + user = src.users.user + end) + + gc() + src.users.user = {} + assert(user ~= nil, "src.users.user should not be released") +end) + return {} diff --git a/packages/charm/test/listen.test.luau b/packages/charm/src/__tests__/listen.spec.luau similarity index 56% rename from packages/charm/test/listen.test.luau rename to packages/charm/src/__tests__/listen.spec.luau index 5d9c335..f3098d1 100644 --- a/packages/charm/test/listen.test.luau +++ b/packages/charm/src/__tests__/listen.spec.luau @@ -1,8 +1,10 @@ -local Charm = require("../src") -local test = require("../../../tests/test") +local Charm = require("../") +local suite = require("../../../../test/suite") local signal = Charm.signal local listen = Charm.listen +local effect = Charm.effect +local test = suite.test test("should listen to selector", function() local source, setSource = signal(0) @@ -25,23 +27,32 @@ test("should listen to selector", function() assert(current == 2 and previous == 1, "should not run after unsub") end) -test("should run in untracked", function() +test("should run in peek", function() local runs = 0 + local cleanups = 0 local a, setA = signal(0) local b, setB = signal(0) listen(a, function() runs += 1 b() + effect(function() + return function() + cleanups += 1 + end + end) end) assert(runs == 1, "should run once on init") + assert(cleanups == 0, "should not cleanup yet") + setA(1) - assert(runs == 2, "should run once on change") + assert(runs == 2, "should run on a change") + assert(cleanups == 1, "should cleanup first call") + setB(1) assert(runs == 2, "should not run on b change") - setA(2) - assert(runs == 3, "should run on a change") + assert(cleanups == 1, "should not cleanup second call") end) test("should run if first value is nil", function() @@ -57,6 +68,36 @@ test("should run if first value is nil", function() assert(runs == 2, "should run once on change") end) +test("should track inner subs", function() + local order = {} + local a, setA = signal(1) + + local unsub = listen(a, function() + table.insert(order, 1) + listen(a, function() + table.insert(order, 2) + end) + listen(a, function() + table.insert(order, 3) + end) + end) + + assert(table.concat(order) == "123", `bad mount order: {table.concat(order)}`) + + table.clear(order) + setA(a() + 1) + assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) + + table.clear(order) + setA(a() + 1) + assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) + + table.clear(order) + unsub() + setA(a() + 1) + assert(table.concat(order) == "", `bad order: {table.concat(order)}`) +end) + test("should skip redundant updates", function() local order = {} local a, setA = signal(0) diff --git a/packages/charm/test/mapped.test.luau b/packages/charm/src/__tests__/mapped.spec.luau similarity index 78% rename from packages/charm/test/mapped.test.luau rename to packages/charm/src/__tests__/mapped.spec.luau index 33620d4..b812aea 100644 --- a/packages/charm/test/mapped.test.luau +++ b/packages/charm/src/__tests__/mapped.spec.luau @@ -1,8 +1,9 @@ -local Charm = require("../src") -local test = require("../../../tests/test") +local Charm = require("../") +local suite = require("../../../../test/suite") local signal = Charm.signal local mapped = Charm.mapped +local test = suite.test test("should map state", function() local src, setSrc = signal({ 1, 2, 3 }) @@ -104,25 +105,4 @@ test("should map keys", function() assert(state.D == 4, "should add d") end) -test("should remove old key if mapper returns a new key", function() - local src, setSrc = signal({ 1 }) - local map = mapped(src, function(value: number, index: number) - return true, `{index}={value}` - end) - - local state = map() - assert(state["1=1"], "should have mapped first element to key 1=1") - - setSrc({ 2 }) - state = map() - assert(state["1=1"] == nil, "should have removed old key 1=1") - assert(state["1=2"], "should have remapped element to key 1=2") - - setSrc({ 3, 1 }) - state = map() - assert(state["1=2"] == nil, "should have removed old key 1=2") - assert(state["1=3"], "should have remapped element to key 1=3") - assert(state["2=1"], "should have mapped second element to key 2=1") -end) - return {} diff --git a/packages/charm/test/observe.test.luau b/packages/charm/src/__tests__/observe.spec.luau similarity index 79% rename from packages/charm/test/observe.test.luau rename to packages/charm/src/__tests__/observe.spec.luau index 64bbd7d..f308477 100644 --- a/packages/charm/test/observe.test.luau +++ b/packages/charm/src/__tests__/observe.spec.luau @@ -1,18 +1,19 @@ -local Charm = require("../src") -local test = require("../../../tests/test") +local Charm = require("../") +local suite = require("../../../../test/suite") local signal = Charm.signal local computed = Charm.computed local effect = Charm.effect local effectScope = Charm.effectScope local observe = Charm.observe +local test = suite.test test("should observe items", function() local items, setItems = signal({ 0, 0, 0 }) local updates = {} local cleanups = {} - observe(items, function(_, key: number) + observe(items, function(_, key) local get = computed(function() return items()[key] end) @@ -91,11 +92,11 @@ end) test("should handle recursive additions and removals", function() local items, setItems = signal({ 1 }) - local edits: { string } = {} + local edits = {} observe(items, function(item) if item < 5 then - setItems(function(state: { number }) + setItems(function(state) state = table.clone(state) table.insert(state, item + 1) return state @@ -114,10 +115,10 @@ end) test("should handle self removal", function() local items, setItems = signal({ 1, 2, 3, 4 }) - local edits: { string } = {} + local edits = {} observe(items, function(item, index) - setItems(function(state: { number }) + setItems(function(state) state = table.clone(state) state[index] = nil return state @@ -134,7 +135,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 +154,7 @@ end) test("should allow disposal during removal", function() local items, setItems = signal({ 1, 2, 3, 4 }) local edits = {} - local dispose: () -> () + local dispose dispose = observe(items, function(item) table.insert(edits, `+{item}`) @@ -167,32 +168,4 @@ test("should allow disposal during removal", function() assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`) end) -test("should not hang on error after recursive update", function() - local items, setItems = signal({}) - - observe(items, function() - setItems({ {} }) - error("test error") - end) - - local success, err = pcall(function() - setItems({ {} }) - end) - assert(not success, "should error") - assert(string.find(tostring(err), "test error"), `bad error: {err}`) -end) - -test("should not hang on error after recursive init", function() - local items, setItems = signal({ {} }) - - local success, err = pcall(function() - observe(items, function() - setItems({ {} }) - error("test error") - end) - end) - assert(not success, "should error") - assert(string.find(tostring(err), "test error"), `bad error: {err}`) -end) - return {} diff --git a/packages/charm/src/__tests__/peek.spec.luau b/packages/charm/src/__tests__/peek.spec.luau new file mode 100644 index 0000000..b786135 --- /dev/null +++ b/packages/charm/src/__tests__/peek.spec.luau @@ -0,0 +1,147 @@ +local Charm = require("../") +local suite = require("../../../../test/suite") + +local signal = Charm.signal +local computed = Charm.computed +local effect = Charm.effect +local peek = Charm.peek +local test = suite.test + +test("should not run detached", function() + local a, setA = signal(1) + local spyCalls = 0 + + effect(function() + a() + peek(function() + effect(function() + return function() + spyCalls += 1 + end + end) + end) + end) + + assert(spyCalls == 0, "should not be called yet") + setA(2) + assert(spyCalls == 1, "should be called once") + setA(3) + assert(spyCalls == 2, "should be called twice") +end) + +test("should block tracking inside effects", function() + local a, setA = signal(1) + local b, setB = signal(2) + local spyCalls = 0 + + effect(function() + peek(function() + spyCalls += 1 + return a() + b() + end) + end) + assert(spyCalls == 1, "should be called once") + + setA(10) + setB(20) + assert(spyCalls == 1, "should be called once") +end) + +test("should block tracking even when run inside effect run inside peek", function() + local s, setS = signal(1) + local spyCalls = 0 + + peek(function() + effect(function() + peek(function() + spyCalls += 1 + return s() + end) + end) + end) + assert(spyCalls == 1, "should be called once") + + setS(2) + assert(spyCalls == 1, "should be called once") +end) + +test("should not cause signal assignments to throw", function() + local a, setA = signal(1) + local aChangedTime, setAChangedTime = signal(0) + + local dispose = effect(function() + a() + peek(function() + setAChangedTime(aChangedTime() + 1) + end) + end) + + setA(2) + assert(aChangedTime() == 2, "aChangedTime should be 2") + setA(3) + assert(aChangedTime() == 3, "aChangedTime should be 3") + + dispose() +end) + +test("should block tracking inside computed signals", function() + local a, setA = signal(1) + local b, setB = signal(2) + local spyCalls = 0 + local c = computed(function() + return peek(function() + spyCalls += 1 + return a() + b() + end) + end) + + assert(spyCalls == 0, "should not be called yet") + assert(c() == 3, "c should be 3") + setA(10) + c() + setB(20) + c() + assert(spyCalls == 1, "should be called once") + assert(c() == 3, "c should be 3") +end) + +test("should track outside peek", function() + local a, setA = signal(1) + local b, setB = signal(2) + local c, setC = signal(3) + local spyCalls = 0 + + effect(function() + a() + peek(c) + b() + spyCalls += 1 + end) + + assert(spyCalls == 1, "should be called once") + setA(10) + assert(spyCalls == 2, "should be called twice") + setB(20) + assert(spyCalls == 3, "should be called three times") + setC(30) + assert(spyCalls == 3, "should not run on c change") +end) + +test("should stack peek", function() + local a, setA = signal(1) + local spyCalls = 0 + + effect(function() + peek(function() + peek(a) + return a() + end) + spyCalls += 1 + end) + + assert(spyCalls == 1, "should be called once") + setA(2) + assert(spyCalls == 1, "should not be called twice") +end) + +return {} diff --git a/packages/deep-charm/test/reactive.test.luau b/packages/charm/src/__tests__/reactive.spec.luau similarity index 70% rename from packages/deep-charm/test/reactive.test.luau rename to packages/charm/src/__tests__/reactive.spec.luau index 41654b1..a2c2d0c 100644 --- a/packages/deep-charm/test/reactive.test.luau +++ b/packages/charm/src/__tests__/reactive.spec.luau @@ -1,13 +1,10 @@ -local Charm = require("../Charm") -local DeepCharm = require("../src") -local test = require("../../../tests/test") +local Charm = require("../") +local suite = require("../../../../test/suite") -local batch = Charm.batch -local computed = Charm.computed local effect = Charm.effect -local observe = Charm.observe -local reactive = DeepCharm.reactive -local toRaw = DeepCharm.toRaw +local reactive = Charm.reactive +local toRaw = Charm.toRaw +local test = suite.test test("should track nested properties", function() local src, setSrc = reactive({ a = { b = { c = 0 } } }) @@ -52,10 +49,10 @@ test("should unwrap proxy assignments", function() end) src.a, src.b = src.a, src.b - assert(runs == 1, `bad runs: {runs}`) + assert(runs == 1, runs) src.a, src.b = src.b, src.a - assert(runs == 3, `bad runs: {runs}`) + assert(runs == 3, runs) end) test("should track iteration", function() @@ -128,10 +125,10 @@ test("should not track unread properties", function() local _ = src.value.a calls += 1 end) - assert(calls == 1, `bad calls: {calls}`) + assert(calls == 1, calls) src.value.b = 3 - assert(calls == 1, `bad calls: {calls}`) + assert(calls == 1, calls) end) test("should overwrite with table passed to setter", function() @@ -162,10 +159,10 @@ test("should unwrap proxy", function() assert(toRaw(src) == raw, "unwrap did not return the original table") assert(toRaw(raw) == raw, "unwrap modified a non-proxy value") - assert(runs == 1, `bad runs: {runs}`) + assert(runs == 1, runs) src.x = 1 - assert(runs == 2, `bad runs: {runs}`) + assert(runs == 2, runs) end) test("should unwrap nested proxy", function() @@ -180,53 +177,13 @@ test("should unwrap nested proxy", function() end) assert(toRaw(src) == raw, "unwrap did not return the original table") - assert(runs == 1, `bad runs: {runs}`) + assert(runs == 1, runs) inner.x = 1 - assert(runs == 2, `bad runs: {runs}`) + assert(runs == 2, runs) src.y = 2 - assert(runs == 3, `bad runs: {runs}`) -end) - -test("should observe items", function() - local items, updateItems = reactive({ 0, 0, 0 }) - local updates = {} - local cleanups = {} - - observe(function() - return toRaw(items) - end, function(_, key: number) - local get = computed(function() - return items[key] - end) - - effect(function() - table.insert(updates, get()) - end) - - return function() - table.insert(cleanups, key) - end - end) - - assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`) - - table.clear(updates) - updateItems(function(state) - state[1] = 1 - state[2] = 2 - state[3] = 3 - end) - assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`) - - table.clear(updates) - batch(function() - items[3] = nil - items[4] = 4 - end) - assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`) - assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`) + assert(runs == 3, runs) end) return {} diff --git a/packages/charm/test/signal.test.luau b/packages/charm/src/__tests__/signal.spec.luau similarity index 62% rename from packages/charm/test/signal.test.luau rename to packages/charm/src/__tests__/signal.spec.luau index 21c1746..a9693f6 100644 --- a/packages/charm/test/signal.test.luau +++ b/packages/charm/src/__tests__/signal.spec.luau @@ -1,12 +1,13 @@ -local Charm = require("../src") -local test = require("../../../tests/test") +local Charm = require("../") +local suite = require("../../../../test/suite") local signal = Charm.signal local effect = Charm.effect +local test = suite.test test("should accept equality function", function() local calls = {} - local src, setSrc = signal(0, function(current: number, incoming: number) + local src, setSrc = signal(0, function(current, incoming) return current == incoming or incoming % 2 == 1 end) @@ -46,22 +47,4 @@ test("should not freeze values with metatables", function() ) end) -test("should setter return same value as getter even if equal", function() - local src, setSrc = signal(0, function() - return true - end) - assert(setSrc(1) == src(), `setSrc(1) expected 0, got {setSrc(1)}`) -end) - -test("should setter return latest value", function() - local src, setSrc = signal(0) - effect(function() - if src() == 1 then - setSrc(2) - end - end) - assert(setSrc(1) == 2, `setSrc(1) expected 2, got {setSrc(1)}`) - assert(src() == 2, `src() expected 2, got {src()}`) -end) - return {} diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau new file mode 100644 index 0000000..17dd1f2 --- /dev/null +++ b/packages/charm/src/__tests__/spec.luau @@ -0,0 +1,46 @@ +local suite = require("../../../../test/suite") + +suite.describe("computed", function() + require("./computed.spec") +end) +suite.describe("effect", function() + require("./effect.spec") +end) +suite.describe("effectScope", function() + require("./effectScope.spec") +end) +suite.describe("gc", function() + require("./gc.spec") +end) +suite.describe("listen", function() + require("./listen.spec") +end) +suite.describe("mapped", function() + require("./mapped.spec") +end) +suite.describe("observe", function() + require("./observe.spec") +end) +suite.describe("peek", function() + require("./peek.spec") +end) +suite.describe("reactive", function() + require("./reactive.spec") +end) +suite.describe("signal", function() + require("./signal.spec") +end) +suite.describe("subscribe", function() + require("./subscribe.spec") +end) +suite.describe("topology", function() + require("./topology.spec") +end) +suite.describe("trigger", function() + require("./trigger.spec") +end) +suite.describe("untracked", function() + require("./untracked.spec") +end) + +return {} diff --git a/packages/charm/test/subscribe.test.luau b/packages/charm/src/__tests__/subscribe.spec.luau similarity index 61% rename from packages/charm/test/subscribe.test.luau rename to packages/charm/src/__tests__/subscribe.spec.luau index 10ab932..938931b 100644 --- a/packages/charm/test/subscribe.test.luau +++ b/packages/charm/src/__tests__/subscribe.spec.luau @@ -1,9 +1,12 @@ -local Charm = require("../src") -local test = require("../../../tests/test") +local Charm = require("../") +local suite = require("../../../../test/suite") local signal = Charm.signal local computed = Charm.computed local subscribe = Charm.subscribe +local listen = Charm.listen +local effect = Charm.effect +local test = suite.test test("should subscribe to selector", function() local source, setSource = signal(0) @@ -26,27 +29,40 @@ test("should subscribe to selector", function() assert(current == 2 and previous == 1, "should not run after unsub") end) -test("should run in untracked", function() +test("should run in peek", function() local runs = 0 + local cleanups = 0 local a, setA = signal(0) local b, setB = signal(0) subscribe(a, function() runs += 1 b() + effect(function() + return function() + cleanups += 1 + end + end) end) assert(runs == 0, "should not run once on init") + assert(cleanups == 0, "should not cleanup yet") + setA(1) assert(runs == 1, "should run once on change") + assert(cleanups == 0, "should not cleanup yet") + setB(1) assert(runs == 1, "should not run on b change") + assert(cleanups == 0, "should not cleanup first call") + setA(2) assert(runs == 2, "should run on a change") + assert(cleanups == 1, "should cleanup first call") end) test("should ensure correct execution order", function() - local order: { number } = {} + local order = {} local n1, setN1 = signal(0) local n2, setN2 = signal(0) local sum = computed(function() @@ -71,8 +87,35 @@ test("should ensure correct execution order", function() assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) end) +test("should track inner subs", function() + local order = {} + local a, setA = signal(1) + + local unsub = subscribe(a, function() + table.insert(order, 1) + listen(a, function() + table.insert(order, 2) + end) + listen(a, function() + table.insert(order, 3) + end) + end) + + setA(a() + 1) + assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) + + table.clear(order) + setA(a() + 1) + assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) + + table.clear(order) + unsub() + setA(a() + 1) + assert(table.concat(order) == "", `bad order: {table.concat(order)}`) +end) + test("should skip redundant updates", function() - local order: { "even" | "odd" } = {} + local order = {} local a, setA = signal(0) subscribe(function(): "even" | "odd" diff --git a/packages/charm/test/topology.test.luau b/packages/charm/src/__tests__/topology.spec.luau similarity index 95% rename from packages/charm/test/topology.test.luau rename to packages/charm/src/__tests__/topology.spec.luau index 3675b3d..6f1e1e0 100644 --- a/packages/charm/test/topology.test.luau +++ b/packages/charm/src/__tests__/topology.spec.luau @@ -1,9 +1,11 @@ -local Charm = require("../src") -local test = require("../../../tests/test") +local Charm = require("../") +local suite = require("../../../../test/suite") local atom = Charm.atom local computed = Charm.computed local effect = Charm.effect +local describe = suite.describe +local test = suite.test --[[ Tests adopted with thanks from preact-core implementation at @@ -32,7 +34,7 @@ local effect = Charm.effect SOFTWARE ]] -test.describe("graph updates", function() +describe("graph updates", function() test("should drop A->B->A updates", function() -- A -- / | @@ -439,19 +441,21 @@ test.describe("graph updates", function() end) end) -test.describe("error handling", function() +describe("error handling", function() test("should keep graph consistent on errors during activation", function() local a = atom(0) - local b = computed(function(): nil + local b = computed(function() error("fail") end) local c = computed(function() return a() end) - local success, result = pcall(b) + local success, result = pcall(function() + b() + end) assert(not success, "should throw") - assert(string.match(tostring(result), "fail"), "should throw fail") + assert(string.match(result, "fail"), "should throw fail") a(1) assert(c() == 1, "c should recover and return 1") @@ -459,7 +463,7 @@ test.describe("error handling", function() test("should keep graph consistent on errors in computeds", function() local a = atom(0) - local b = computed(function(): number + local b = computed(function() if a() == 1 then error("fail") end @@ -472,9 +476,11 @@ test.describe("error handling", function() assert(c() == 0, "c should be 0") a(1) - local success, result = pcall(b) + local success, result = pcall(function() + b() + end) assert(not success, "should throw") - assert(string.match(tostring(result), "fail"), "should throw fail") + assert(string.match(result, "fail"), "should throw fail") a(2) assert(c() == 2, "c should recover and return 2") diff --git a/packages/charm/test/trigger.test.luau b/packages/charm/src/__tests__/trigger.spec.luau similarity index 78% rename from packages/charm/test/trigger.test.luau rename to packages/charm/src/__tests__/trigger.spec.luau index 20ebf32..d0b1938 100644 --- a/packages/charm/test/trigger.test.luau +++ b/packages/charm/src/__tests__/trigger.spec.luau @@ -1,18 +1,20 @@ -local Charm = require("../src") -local test = require("../../../tests/test") +local Charm = require("../") +local suite = require("../../../../test/suite") local computed = Charm.computed local effect = Charm.effect -local listen = Charm.listen local signal = Charm.signal local subscribe = Charm.subscribe local trigger = Charm.trigger +local test = suite.test +local beforeEach = suite.beforeEach +local afterEach = suite.afterEach -test.beforeEach(function() +beforeEach(function() Charm.flags.frozen = false end) -test.afterEach(function() +afterEach(function() Charm.flags.frozen = true end) @@ -68,7 +70,7 @@ test("should trigger effect once", function() end) test("should not notify the trigger function sub", function() - local src1 = signal({} :: { number }) + local src1 = signal({}) local src2 = computed(function() return src1() end) @@ -91,21 +93,9 @@ test("should trigger updates for subscribe", function() triggers += 1 end) + table.insert(src(), 1) trigger(src) assert(triggers == 1, "subscribe callback should run after trigger") end) -test("should trigger updates for listen", function() - local src = signal({} :: { number }) - local triggers = 0 - - listen(src, function() - triggers += 1 - end) - assert(triggers == 1, "listen callback should run once initially") - - trigger(src) - assert(triggers == 2, "listen callback should run after trigger") -end) - return {} diff --git a/packages/charm/test/untracked.test.luau b/packages/charm/src/__tests__/untracked.spec.luau similarity index 96% rename from packages/charm/test/untracked.test.luau rename to packages/charm/src/__tests__/untracked.spec.luau index 3c7fea4..d4bfc4c 100644 --- a/packages/charm/test/untracked.test.luau +++ b/packages/charm/src/__tests__/untracked.spec.luau @@ -1,11 +1,12 @@ -local Charm = require("../src") -local test = require("../../../tests/test") +local Charm = require("../") +local suite = require("../../../../test/suite") local signal = Charm.signal local computed = Charm.computed local effect = Charm.effect local effectScope = Charm.effectScope local untracked = Charm.untracked +local test = suite.test test("should run detached from effect", function() local a, setA = signal(1) @@ -38,7 +39,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/src/index.d.ts b/packages/charm/src/index.d.ts index e4689e5..132a5be 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -1,15 +1,15 @@ type Key = string | number | symbol; - type Cleanup = () => void; export enum ReactiveFlags { - None = 0b000000, - Mutable = 0b000001, - Watching = 0b000010, - RecursedCheck = 0b000100, - Recursed = 0b001000, - Dirty = 0b010000, - Pending = 0b100000, + None = 0b0000000, + Mutable = 0b0000001, + Watching = 0b0000010, + RecursedCheck = 0b0000100, + Recursed = 0b0001000, + Dirty = 0b0010000, + Pending = 0b0100000, + Peeking = 0b1000000, } export interface ReactiveNode { @@ -31,15 +31,11 @@ export interface Link { } export interface Atom { - (update: Update): T; + (newValue: T | ((currentValue: T) => T)): T; (): T; } -export type Getter = () => T; - -export type Setter = (update: Update) => T; - -export type Update = ((currentValue: T) => T) | T; +export type Setter = (newValue: T | ((currentValue: T) => T)) => T; export type Equals = (current: T, incoming: T) => boolean; @@ -50,12 +46,12 @@ export const flags: { /** * Enforces synchronous, non-yielding behavior in signals and effects. * Also enables state validation in Charm Sync to catch sync errors early. - * Enabled by default in Roblox Studio. + * Enabled if the optimization level is below 2, which is true in studio. */ strict: boolean; /** * Enforces immutability of tables by deep-freezing table values. Enabled - * by default in Roblox Studio. + * if the optimization level is below 2, which is true in studio. */ frozen: boolean; /** @@ -75,8 +71,13 @@ export const flags: { * @returns A tuple containing the getter and setter functions for the signal. * @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals */ -export function signal(initialValue: T, equals?: Equals): LuaTuple<[getter: Getter, setter: Setter]>; -export function signal(): LuaTuple<[getter: Getter, setter: Setter]>; +export function signal( + initialValue: T, + equals?: Equals, +): LuaTuple<[getter: () => T, setter: (newValue: T | ((currentValue: T) => T)) => T]>; +export function signal(): LuaTuple< + [getter: () => T | undefined, setter: (newValue: T | ((currentValue?: T) => T | undefined)) => T | undefined] +>; /** * Creates a reactive atom that acts as both a signal getter and setter. @@ -98,7 +99,7 @@ export function atom(): Atom; * @param getter A function that produces the next value. * @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter */ -export function computed(getter: (previousValue?: T) => T): Getter; +export function computed(getter: (previousValue?: T) => T): () => T; /** * Creates an effect that runs a callback in response to signal state changes. @@ -149,12 +150,10 @@ export function trigger(fn: () => void): void; export function onCleanup(fn: Cleanup, failSilently?: boolean): void; /** - * Runs the function without subscribing to signal updates and prevents the - * current effect or scope from tracking inner effects created within the - * function. + * Runs the function without subscribing to signal updates or capturing inner + * effects in the parent effect or scope. * - * To avoid tracking signals while still allowing inner effects to be tracked - * by parent effects/scopes, use `effectScope()` instead. + * For an alternative that still cleans up inner effects, use `peek` instead. * * @param fn A function that may read signals or create effects. * @param args Arguments to pass to the function. @@ -163,6 +162,19 @@ export function onCleanup(fn: Cleanup, failSilently?: boolean): void; */ export function untracked(fn: (...args: Args) => Result, ...args: Args): Result; +/** + * Runs the function without subscribing to signal updates, but still captures + * inner effects so that they dispose with the parent effect. + * + * To avoid capturing inner effects, use `untracked` instead. + * + * @param fn A function that may read signals. + * @param args Arguments to pass to the function. + * @returns The return value of the function. + * @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback + */ +export function peek(fn: (...args: Args) => Result, ...args: Args): Result; + /** * Combines multiple updates into a single "commit" that runs effects and * computed signals after the provided function finishes running. @@ -179,51 +191,54 @@ export function batch(fn: (...args: Args) => Result, * when the value returned by the getter changes. The callback receives the new * value and the previous value as arguments. * + * Effects created within the callback are cleaned up when the listener runs + * or the listener is disposed. + * * @param getter A function that returns the value to watch. - * @param callback The function to run when the value changes. + * @param fn The function to run when the value changes. * @returns A function for disposing the effect. * @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback */ -export function listen(getter: Getter, callback: (value: T, previousValue: T | undefined) => void): Cleanup; +export function listen(getter: () => T, fn: (value: T, previousValue: T | undefined) => void): Cleanup; /** * Creates an effect that only runs the callback when the value returned by the * getter changes. The callback receives the new value and the previous value * as arguments. * + * Effects created within the callback are cleaned up when the listener runs + * or the listener is disposed. + * * @param getter A function that returns the value to watch. - * @param callback The function to run when the value changes. + * @param fn The function to run when the value changes. * @returns A function for disposing the effect. * @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback */ -export function subscribe(getter: Getter, callback: (value: T, previousValue: T) => void): Cleanup; +export function subscribe(getter: () => T, fn: (value: T, previousValue: T) => void): Cleanup; /** * Creates a new read-only signal that is computed by mapping over the entries - * of the source table. If the key is omitted from the transform function's - * return value, the original key is preserved. + * of an existing atom. If the key is omitted from the mapper's return value, + * the original key is preserved. * - * This function is optimized to minimize unnecessary updates by only calling - * the transform function for entries that have changed since the last run. - * - * @param getter A signal or function that returns a table - * @param transform Function that maps each entry from the source to a new entry in the mapped table - * @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform + * @param getter Returns the table to map over. + * @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. + * @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper */ export function mapped( - getter: Getter | ReadonlyMap>, - transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, -): Getter>; + getter: () => Map | ReadonlyMap, + mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, +): () => ReadonlyMap; // Overload for arrays export function mapped( - getter: Getter, - transform: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, -): Getter; + getter: () => readonly VI[], + mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, +): () => K extends number ? readonly VO[] : { readonly [P in K]: VO }; // Overload for objects export function mapped( - getter: Getter<{ readonly [K in KI]: VI }>, - transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, -): Getter<{ readonly [K in KO]: VO }>; + getter: () => { readonly [K in KI]: VI }, + mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, +): () => { readonly [K in KO]: VO }; /** * Calls the callback function for each unique key in the table returned by the @@ -237,44 +252,86 @@ export function mapped( * from the table or when the callback is disposed. * * @param getter A function that returns the table to observe. - * @param callback A function that is called for each key in the table. + * @param fn A function that is called for each key in the table. * @returns A function for disposing the observer. * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback */ export function observe( - getter: Getter | ReadonlyMap | readonly V[]>, - callback: (value: V, key: K) => Cleanup | void, + getter: () => Map | ReadonlyMap | readonly V[], + fn: (value: V, key: K) => Cleanup | void, ): Cleanup; // Overload for objects export function observe( - getter: Getter<{ readonly [K in Key]: V }>, - callback: (value: V, key: K) => Cleanup | void, + getter: () => { readonly [K in Key]: V }, + fn: (value: V, key: K) => Cleanup | void, ): Cleanup; /** - * Returns the currently active subscriber (effect or computed node). + * Disables recursive checks for the current effect or computed signal. + * Useful for effects that intentionally update signals they depend on. * - * @return Current active subscriber or `nil` + * @example + * ```ts + * effect(() => { + * recursive(); + * setCount(getCount() + 1); + * }) + * ``` */ +export function recursive(): void; + +/** + * Creates a deep reactive value that tracks properties when they're accessed. + * Returns a proxy object that subscribes to changes on any nested property + * you read, and a function for mutating the original table. + * + * @example + * ```luau + * local users, setUsers = reactive({ + * user = { name = "John", surname = "Doe" }, + * }) + * + * effect(function() + * print(users.user and `{users.user.name} {users.user.surname}`) + * end) + * + * setUsers(function(state) + * state.user.name = "Jane" + * state.user.surname = "Smith" + * end) + * ``` + * + * @param initialValue The value to make deeply reactive. + * @returns A proxy table and a function to mutate the original table. + * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue + */ +export function reactive( + initialValue: T, +): LuaTuple<[value: T, setter: (update: T | ((initialValue: T) => void)) => T]>; + +/** + * Determines if the provided value is a reactive proxy created by `reactive()`. + * + * @param value A value to test. + * @returns `true` if the value is a reactive proxy, `false` otherwise. + * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue + */ +export function isReactive(value: unknown): boolean; + +/** + * Unwraps a reactive proxy to get the original table. If the provided value + * is not a proxy, it is returned as-is. + * + * @param value A reactive proxy or a non-proxy value. + * @returns The unwrapped target value. + * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue + */ +export function toRaw(value: T): T; + export function getActiveSub(): ReactiveNode | undefined; -/** - * 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 - */ export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined; -/** - * Starts a batch operation, incrementing the batch depth counter. Effects - * will not run until the outermost batch ends. - */ export function startBatch(): void; -/** - * Ends a batch operation, decrementing the batch depth counter. If this is - * the outermost batch, queued effects are flushed and run. - */ export function endBatch(): void; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 0ba77d7..886605d 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,35 +1,29 @@ ---[[ - Reactive system adapted from alien-signals at - https://github.com/stackblitz/alien-signals/blob/master/src/system.ts -]] - local system = require("@self/system") -export type Link = system.Link export type ReactiveFlags = system.ReactiveFlags + export type ReactiveNode = system.ReactiveNode -local link = system.link -local unlink = system.unlink -local propagate = system.propagate -local checkDirty = system.checkDirty -local shallowPropagate = system.shallowPropagate -local createReactiveSystem = system.createReactiveSystem +export type Link = system.Link -export type Getter = () -> T -export type Setter = (update: Update) -> T -export type Atom = (...Update) -> T -export type Update = ((current: T) -> T) | T -export type Equals = (current: T, incoming: T) -> boolean -export type Cleanup = () -> () +export type Atom = (...((T) -> T) | T) -> T + +export type ReactiveProxy = typeof(setmetatable( + {} :: { + __track: () -> (), + __target: T, + __signals: { [unknown]: () -> unknown }, + }, + {} :: { __index: T } +)) type EffectScopeNode = ReactiveNode & { - cleanups: { Cleanup }, + cleanups: { () -> () }, } type EffectNode = ReactiveNode & { - fn: () -> ...Cleanup?, - cleanups: { Cleanup }, + fn: () -> ...(() -> ())?, + cleanups: { () -> () }, } type ComputedNode = ReactiveNode & { @@ -42,48 +36,30 @@ type SignalNode = ReactiveNode & { pendingValue: T, } --- Bitmask flags for tracking the state of reactive nodes. Flags are combined --- and checked using bitwise operations for efficiency. -local NONE = 0b000000 -- Default state -local MUTABLE = 0b000001 -- Can be written to (signal or computed) -local WATCHING = 0b000010 -- Notified for changes in dependencies (effects) -local RECURSED_CHECK = 0b000100 -- Being checked for circular dependencies -local RECURSED = 0b001000 -- Has been visited in a recursion check -local DIRTY = 0b010000 -- Value is outdated and needs recomputation -local PENDING = 0b100000 -- Might be dirty, pending verification +local NONE = 0b0000000 +local MUTABLE = 0b0000001 +local WATCHING = 0b0000010 +local RECURSED_CHECK = 0b0000100 +local DIRTY = 0b0010000 +local PENDING = 0b0100000 +local PEEKING = 0b1000000 --- Global version counter for dependency link deduplication. Incremented at the --- start of each effect/computed run to ensure links created in the same cycle --- are deduplicated. -local cycle = 0 --- Effects runs are queued to prevent redundant executions when multiple --- signals update in the same batch. -local queued: { EffectNode } = {} -local queuedLength = 0 -local notifyIndex = 0 --- Batch operations group multiple updates together, deferring effect runs --- until the batch completes. Effects only run when the outermost batch ends. -local batchDepth = 0 --- Tracks the currently active effect or computed signal during execution. --- Dependencies accessed during this time will link to the active subscriber. -local activeSub: ReactiveNode? - -local function isStudio(): boolean - return game ~= nil and game:GetService("RunService"):IsStudio() +local function inlineTest(): string + return debug.info(1, "n") end local flags = { --[[ Enforces synchronous, non-yielding behavior in signals and effects. Also enables state validation in Charm Sync to catch sync errors early. - Enabled by default in Roblox Studio. + Enabled if the optimization level is below 2, which is true in studio. ]] - strict = isStudio(), + strict = inlineTest() == "inlineTest", --[[ Enforces immutability of tables by deep-freezing table values. Enabled - by default in Roblox Studio. + if the optimization level is below 2, which is true in studio. ]] - frozen = isStudio(), + frozen = inlineTest() == "inlineTest", --[[ 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,77 +68,60 @@ local flags = { trackInnerEffects = true, } ---[[ - 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 wrapUserSpace(callback: (Args...) -> Results...): (Args...) -> Results... +local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> () +local unlink: (link: Link, sub: ReactiveNode?) -> Link? +local propagate: (link: Link) -> () +local checkDirty: (link: Link, sub: ReactiveNode) -> boolean +local shallowPropagate: (link: Link) -> () + +local ReactiveProxy = { + proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }), +} + +local cycle = 0 +local batchDepth = 0 +local notifyIndex = 0 +local queued: { EffectNode? } = {} +local queuedLength = 0 +local activeSub: ReactiveNode? + +local function noYield(fn: (Args...) -> Results...): (Args...) -> Results... if not flags.strict then - return callback + return fn end - - -- If the sources match, the function was created in this module - if debug.info(1, "s") == debug.info(callback, "s") then - return callback - end - - local handler = function(thread: thread, ok: boolean, ...: Results...): Results... + local function resultHandler(thread: thread, ok: boolean, ...) if not ok then - local err = (...) - if type(err) == "string" then - error(debug.traceback(thread, err), 0) - else - error(tostring(err), 0) - end + error(debug.traceback(thread, tostring((...)))) elseif coroutine.status(thread) ~= "dead" then - error(debug.traceback(thread, "Attempted to yield in an effect or scope"), 0) - else - return ... + error(debug.traceback(thread, "Yielding is not allowed in strict mode")) end + return ... end - - return function(...: Args...): Results... - local thread = coroutine.create(callback) - return handler(thread, coroutine.resume(thread, ...)) + return function(...) + local thread = coroutine.create(fn) + return resultHandler(thread, coroutine.resume(thread, ...)) end end ---[[ - Recursively freezes a table and all nested tables to enforce immutability. - Tables with metatables are skipped, as they are assumed to be mutable. -]] -local function deepFreeze(value: any) - if type(value) == "table" and not table.isfrozen(value) and getmetatable(value) == nil then - table.freeze(value) - for _, entry in value do - deepFreeze(entry) +local function deepFreeze(object: any) + if type(object) == "table" and not table.isfrozen(object) and getmetatable(object) == nil then + table.freeze(object) + for _, value in object do + deepFreeze(value) end end end ---[[ - Returns the currently active subscriber (effect or computed node). -]] local function getActiveSub(): ReactiveNode? return activeSub end ---[[ - Sets the currently active subscriber (effect or computed node) and returns - the previous subscriber. -]] local function setActiveSub(sub: ReactiveNode?) local prevSub = activeSub activeSub = sub return prevSub end ---[[ - Removes dependencies from the subscriber's dependency list. When called - after tracking is complete, this ensures that only currently tracked - dependencies remain linked. -]] local function purgeDeps(sub: ReactiveNode) local depsTail = sub.depsTail local dep = if depsTail then depsTail.nextDep else sub.deps @@ -171,193 +130,204 @@ local function purgeDeps(sub: ReactiveNode) end end ---[[ - Runs all cleanup functions registered with the effect node. Any errors - thrown during cleanup are caught and logged in a single error after all - cleanups are complete. -]] -local function runCleanups(cleanups: { Cleanup }) - local hasErrors = false - local errors: { string } = {} - - for index, cleanup in cleanups do - cleanups[index] = nil - +local function runCleanups(effect: EffectScopeNode) + local cleanups = effect.cleanups + for key, cleanup in cleanups do local prevSub = setActiveSub(nil) - local success, err = pcall(cleanup) + local success, result: any = pcall(cleanup) activeSub = prevSub - + cleanups[key] = nil if not success then - hasErrors = true - table.insert(errors, tostring(err)) + warn("An error occurred during effect cleanup:", result) 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. -]] -local function run(effect: EffectNode) - local effectFlags = effect.flags - - 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 - +local function run(e: EffectNode) + local flags = e.flags + if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then + runCleanups(e) cycle += 1 - effect.depsTail = nil - effect.flags = WATCHING - - local prevSub = setActiveSub(effect) - local success, result = pcall(effect.fn) + e.depsTail = nil + e.flags = bit32.bor(WATCHING, RECURSED_CHECK) + local prevSub = setActiveSub(e) + local success, result = pcall(e.fn) activeSub = prevSub - - purgeDeps(effect) - - if success and result then - table.insert(effect.cleanups, wrapUserSpace(result)) - elseif not success then - error(result, 0) - end - - -- If the effect was disposed during execution but before a cleanup was - -- was registered, run the cleanups that would have been missed - if effect.flags == NONE then - runCleanups(effect.cleanups) + e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) + purgeDeps(e) + if not success then + error(result) + elseif result then + table.insert(e.cleanups, noYield(result)) end else - effect.flags = WATCHING + e.flags = WATCHING end end ---[[ - Processes the queued effect runs. Effects are run in the order they were - queued, ensuring that all updates are processed. -]] local function flush() - -- Bail out early if a flush is already in progress to allow effects to - -- complete before the next effect runs - if batchDepth ~= 0 or notifyIndex ~= 0 then - return - end - - local success, err = pcall(function() - while notifyIndex < queuedLength do - notifyIndex += 1 - local effect = queued[notifyIndex] :: EffectNode - queued[notifyIndex] = nil - run(effect) - end - end) - - -- Skip over any remaining queued effects that were not run due to an error while notifyIndex < queuedLength do - notifyIndex += 1 local effect = queued[notifyIndex] :: EffectNode queued[notifyIndex] = nil - effect.flags = bit32.bor(effect.flags, bit32.bor(WATCHING, RECURSED)) + notifyIndex += 1 + run(effect) end - notifyIndex = 0 queuedLength = 0 - - if not success then - error(err, 0) - end end ---[[ - Starts a batch operation, incrementing the batch depth counter. Effects - will not run until the outermost batch ends. -]] local function startBatch() batchDepth += 1 end ---[[ - Ends a batch operation, decrementing the batch depth counter. If this is - the outermost batch, queued effects are flushed and run. -]] local function endBatch() batchDepth -= 1 - flush() -end - ---[[ - Updates a computed signal by re-evaluating its getter function. Returns - whether the computed value changed. -]] -local function updateComputed(computed: ComputedNode): boolean - cycle += 1 - computed.depsTail = nil - computed.flags = bit32.bor(MUTABLE, RECURSED_CHECK) - - local prevSub = setActiveSub(computed) - local oldValue = computed.value - local success, newValue = pcall(computed.getter, oldValue) - activeSub = prevSub - - computed.flags = bit32.band(computed.flags, bit32.bnot(RECURSED_CHECK)) - purgeDeps(computed) - - if success then - computed.value = newValue - return oldValue ~= newValue - else - error(newValue, 0) + if batchDepth == 0 then + flush() end end ---[[ - Updates a signal by applying its pending value. Returns whether the - signal's value changed. -]] -local function updateSignal(signal: SignalNode): boolean - signal.flags = MUTABLE - local pendingValue = signal.pendingValue - if signal.currentValue ~= pendingValue then - signal.currentValue = pendingValue +local function updateComputed(c: ComputedNode): boolean + cycle += 1 + c.depsTail = nil + c.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + local prevSub = setActiveSub(c) + local oldValue = c.value + local success, result = pcall(c.getter, oldValue) + activeSub = prevSub + c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK)) + purgeDeps(c) + if not success then + error(result) + end + if oldValue ~= result then + c.value = result return true else return false end end ---[[ - Clears all dependencies and subscribers from the effect node. After calling - this, the node will no longer be subscribed to any dependencies and will be - inactive, with all cleanup functions run and resources released. -]] -local function stopEffect(effect: EffectScopeNode) - effect.depsTail = nil - effect.flags = NONE - - purgeDeps(effect) - - -- Unlink from the parent effect, if it exists - local sub = effect.subs - if sub then - unlink(sub, effect) +local function updateSignal(s: SignalNode): boolean + s.flags = MUTABLE + local pendingValue = s.pendingValue + if s.currentValue ~= pendingValue then + s.currentValue = pendingValue + return true + else + return false + end +end + +local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags) + sub.flags = bit32.band(flags, bit32.bnot(PENDING)) +end + +local function computedOper(this: ComputedNode): () -> T + local function readComputed(): T + local flags = this.flags + if + bit32.btest(flags, DIRTY) + or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or setNotPending(this, flags))) + then + if updateComputed(this) then + local subs = this.subs + if subs then + shallowPropagate(subs) + end + end + elseif flags == NONE then + this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + local prevSub = setActiveSub(this) + local success, result = pcall(this.getter) + activeSub = prevSub + this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK)) + if success then + this.value = result + else + error(result) + end + end + if activeSub then + link(this, activeSub, cycle) + end + return this.value :: T + end + return readComputed +end + +local function signalOper( + this: SignalNode, + equals: ((current: T, incoming: T) -> boolean)? +): (() -> T, (((T) -> T) | T) -> T) + local function writeSignal(update: ((T) -> T) | T): T + local value: T + if type(update) == "function" then + local prevSub = setActiveSub(nil) + local success, result = pcall(noYield(update), this.pendingValue) + activeSub = prevSub + if not success then + error(result) + end + value = result + else + value = update + end + 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 + if bit32.btest(this.flags, DIRTY) then + if updateSignal(this) then + local subs = this.subs + if subs then + shallowPropagate(subs) + end + end + end + local sub = activeSub + while sub do + if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then + link(this, sub, cycle) + break + end + local subs = sub.subs + sub = if subs then subs.sub else nil + end + return this.currentValue + end + + return readSignal, writeSignal +end + +local function effectOper(this: EffectScopeNode): () -> () + return function() + this.depsTail = nil + this.flags = NONE + purgeDeps(this) + local sub = this.subs + if sub then + unlink(sub, this) + end + runCleanups(this) end - - runCleanups(effect.cleanups) end ---[[ - Updates a signal or computed value and returns whether the value changed. -]] local function update(node: ReactiveNode): boolean if node.depsTail then return updateComputed(node :: ComputedNode) @@ -366,52 +336,35 @@ local function update(node: ReactiveNode): boolean end end ---[[ - Queues an effect for execution, ensuring inner effects are notified in the - correct order. -]] local function notify(effect: ReactiveNode) - local startIndex = queuedLength + 1 + local insertIndex = queuedLength + local firstInsertedIndex = insertIndex - -- Collect inner effects in a chain repeat - queuedLength += 1 - queued[queuedLength] = effect :: EffectNode - - -- 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 + queued[insertIndex] = effect :: EffectNode + insertIndex += 1 effect = (effect.subs and effect.subs.sub) :: EffectNode - if not effect or bit32.band(effect.flags, WATCHING) == 0 then break end until false - local endIndex = queuedLength + queuedLength = insertIndex - -- Reverse the collected effects since they were collected from child to - -- parent, but should be notified from parent to child - while startIndex < endIndex do - queued[startIndex], queued[endIndex] = queued[endIndex], queued[startIndex] - startIndex += 1 - endIndex -= 1 + insertIndex -= 1 + while firstInsertedIndex < insertIndex do + local left = queued[firstInsertedIndex] + queued[firstInsertedIndex] = queued[insertIndex] + queued[insertIndex] = left + firstInsertedIndex += 1 + insertIndex -= 1 end end ---[[ - Called when a node is not being watched by any subscribers. Cleans up the - node's dependencies and performs the necessary cleanup operations: - - - Computed signals: Unlink dependencies and mark as dirty for potential - future updates - - Effects/scopes: Perform full cleanup to free resources -]] local function unwatched(node: ReactiveNode) if bit32.band(node.flags, MUTABLE) == 0 then - stopEffect(node :: EffectScopeNode) + effectOper(node :: EffectScopeNode)() elseif node.depsTail then node.depsTail = nil node.flags = bit32.bor(MUTABLE, DIRTY) @@ -423,106 +376,40 @@ end Creates a reactive signal that stores a value and notifies subscribers when the value changes. The signal provides both a getter and a setter. - @param initialValue Initial value for the signal - @param equals Optional comparator function to check for value changes - @return Getter function to read the signal value - @return Setter function to update the signal value + @param initialValue The initial value of the signal. + @param equals A comparator function to determine if the signal's value has changed. + @return A function to get the current value of the signal. + @return A function to set a new value and notify subscribers. @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals ]] -local function signal(initialValue: T, equals: Equals?): (Getter, Setter) - local node: SignalNode = { - currentValue = initialValue, - pendingValue = initialValue, - flags = MUTABLE, - } - +local function signal( + initialValue: T, + equals: ((current: T, incoming: T) -> boolean)? +): (() -> T, (((T) -> T) | T) -> T) if flags.frozen then deepFreeze(initialValue) end - - local function signalGetter(): T - -- Check if the signal has a pending update that needs to be applied - if bit32.btest(node.flags, DIRTY) then - if updateSignal(node) then - local subs = node.subs - if subs then - shallowPropagate(subs) - end - end - end - - -- Register this signal as a dependency of the first subscriber in the - -- chain that is a computed signal or an effect - local sub = activeSub - while sub do - if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then - link(node, sub, cycle) - break - end - local subs = sub.subs - sub = if subs then subs.sub else nil - end - - return node.currentValue - end - - local function signalSetter(update: Update): T - local 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 + return signalOper({ + currentValue = initialValue, + pendingValue = initialValue, + flags = MUTABLE, + }, equals) end --[[ - Creates a signal and returns a function that can act as both the getter and - setter. Calling the atom with one argument sets its value, while calling it - with no arguments returns its current value. + Creates a reactive atom that acts as both a signal getter and setter. + Calling the atom with an argument sets its value, while calling it with no + arguments returns its current value. - @param initialValue Initial value for the atom - @param equals Optional comparator function to check for value changes + @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`. + @param equals A comparator function to determine if the atom's value has changed. @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals ]] -local function atom(initialValue: T, equals: Equals?): Atom +local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): Atom local get, set = signal(initialValue, equals) - local function atomOper(...) return if select("#", ...) == 0 then get() else set((...)) end - return atomOper end @@ -531,62 +418,14 @@ end other signals. The computed signal's value is automatically updated when a change is detected in any of its dependencies. - @param getter Function that calculates the next value - @return A function that returns the cached value + @param getter A function that produces the next value. @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter ]] -local function computed(getter: (previousValue: T?) -> T): Getter - local node: ComputedNode = { +local function computed(getter: (previousValue: T?) -> T): () -> T + return computedOper({ flags = NONE, - getter = wrapUserSpace(getter), - } - - local function computedOper(): T - local nodeFlags = node.flags - - if bit32.btest(nodeFlags, DIRTY) then - if updateComputed(node) then - local subs = node.subs - if subs then - shallowPropagate(subs) - end - end - elseif bit32.btest(nodeFlags, PENDING) then - if checkDirty(node.deps :: Link, node) then - if updateComputed(node) then - local subs = node.subs - if subs then - shallowPropagate(subs) - end - end - else - node.flags = bit32.band(nodeFlags, bit32.bnot(PENDING)) - end - elseif nodeFlags == NONE then - -- First access should initialize the computed value - node.flags = bit32.bor(MUTABLE, RECURSED_CHECK) - - local prevSub = setActiveSub(node) - local success, result = pcall(node.getter) - activeSub = prevSub - - node.flags = bit32.band(node.flags, bit32.bnot(RECURSED_CHECK)) - - if success then - node.value = result - else - error(result, 2) - end - end - - if activeSub then - link(node, activeSub, cycle) - end - - return node.value - end - - return computedOper + getter = noYield(getter), + }) end --[[ @@ -600,113 +439,89 @@ end If the effect is called within another effect, it will be disposed when the parent effect re-runs. - @param fn Function to execute reactively - @return A cleanup function that stops the effect + @param fn The function to run when dependencies change. + @return A function for disposing the effect. @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback ]] -local function effect(fn: () -> ...Cleanup?): Cleanup - local node: EffectNode = { - fn = wrapUserSpace(fn), - flags = WATCHING, +local function effect(fn: () -> ...(() -> ())?): () -> () + local e: EffectNode = { + fn = noYield(fn), + flags = bit32.bor(WATCHING, RECURSED_CHECK), cleanups = {}, } - - -- Inner effects are tracked as a dependency of the parent effect, so that - -- they can be automatically cleaned up when the parent effect re-runs, and - -- to ensure the correct execution order when nested effects are notified - if activeSub and (flags.trackInnerEffects or activeSub.flags == NONE) then - link(node, activeSub, 0) + local prevSub = setActiveSub(e) + if prevSub and (flags.trackInnerEffects or prevSub.flags == NONE) then + link(e, prevSub, 0) end - - -- Start a batch so that the effect can register a cleanup function before - -- it can receive notifications, ensuring the cleanup always runs - startBatch() - - local prevSub = setActiveSub(node) - local success, result = pcall(node.fn) + local success, result = pcall(e.fn) activeSub = prevSub - - if success and result then - table.insert(node.cleanups, wrapUserSpace(result)) - elseif not success then - endBatch() - error(result, 2) - end - - endBatch() - - return function() - stopEffect(node) + e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) + if not success then + error(result) + elseif result then + table.insert(e.cleanups, noYield(result)) end + return effectOper(e) end --[[ Creates an effect scope that can capture reactive effects and computed signals created within it so that they can be disposed together. - @param fn Function that creates effects within the scope - @param detached If true, the scope will not be tracked by the parent effect or scope - @return A cleanup function that stops all effects in the scope + @param fn A function that may create reactive effects. + @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. + @return A function for disposing all effects created within the scope. @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached ]] -local function effectScope(fn: () -> ...Cleanup?, detached: boolean?): Cleanup - local node: EffectScopeNode = { +local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () + local e: EffectScopeNode = { flags = NONE, cleanups = {}, } - - -- Inner effect scopes are tracked as a dependency of the parent effect - if activeSub and not detached then - link(node, activeSub, 0) + local prevSub = setActiveSub(e) + if prevSub and not detached then + link(e, prevSub, 0) end - - local prevSub = setActiveSub(node) - local success, result = pcall(wrapUserSpace(fn)) + local success, result = pcall(noYield(fn)) activeSub = prevSub - - if success and result then - table.insert(node.cleanups, wrapUserSpace(result)) - elseif not success then - error(result, 2) - end - - return function() - stopEffect(node) + if not success then + error(result) + elseif result then + table.insert(e.cleanups, noYield(result)) end + return effectOper(e) end --[[ Allows you to manually trigger updates for downstream dependencies when you've directly mutated a signal's value without using the signal setter. - @param fn A signal or function that accesses signals + @param fn The dependency or a function that calls multiple dependencies. @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback ]] -local function trigger(fn: () -> ...any) +local function trigger(fn: () -> ()) local sub: ReactiveNode = { flags = WATCHING, } - local prevSub = setActiveSub(sub) - local success, err = pcall(wrapUserSpace(fn)) + local success, result: any = pcall(noYield(fn)) activeSub = prevSub - sub.flags = NONE - - local currentLink = sub.deps - while currentLink do - local dep = currentLink.dep + local link = sub.deps + while link do + local dep = link.dep + link = unlink(link, sub) local subs = dep.subs - currentLink = unlink(currentLink, sub) if subs then + sub.flags = NONE propagate(subs) shallowPropagate(subs) end end - - flush() - + if batchDepth == 0 then + flush() + end if not success then - error(err, 2) + error(result) end end @@ -715,41 +530,66 @@ end there is no active effect, a warning is logged, unless `failSilently` is set to `true`. - @param fn Function to run during cleanup - @param failSilently If true, silences the warning when called outside an effect or scope + @param fn The cleanup function to register. + @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently ]] -local function onCleanup(fn: Cleanup, failSilently: boolean?) +local function onCleanup(fn: () -> (), failSilently: boolean?) if activeSub and (activeSub :: EffectNode).cleanups then - table.insert((activeSub :: EffectNode).cleanups, wrapUserSpace(fn)) + table.insert((activeSub :: EffectNode).cleanups, noYield(fn)) elseif not failSilently then warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) end end --[[ - Runs the function without subscribing to signal updates and prevents the - current effect or scope from tracking inner effects created within the - function. + Runs the function without subscribing to signal updates or capturing inner + effects in the parent effect or scope. - To avoid tracking signals while still allowing inner effects to be tracked - by parent effects/scopes, use `effectScope()` instead. + For an alternative that still cleans up inner effects, use `peek` instead. - @param fn A signal or function that accesses signals - @param ...args Arguments to pass to the function - @return Values returned by the function + @param fn A function that may read signals or create effects. + @param ...args Arguments to pass to the function. + @return The return value of the function. @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback ]] local function untracked(fn: (Args...) -> Results..., ...: Args...): Results... if activeSub then local prevSub = setActiveSub(nil) - local results: { any } = { pcall(wrapUserSpace(fn), ...) } + local results: { any } = { pcall(noYield(fn), ...) } activeSub = prevSub - if not results[1] then - error(results[2], 2) + error(results[2]) end + return unpack(results, 2) + else + return fn(...) + end +end +--[[ + Runs the function without subscribing to signal updates, but still captures + inner effects so that they dispose with the parent effect. + + To avoid capturing inner effects, use `untracked` instead. + + @param fn A function that may read signals. + @param ...args Arguments to pass to the function. + @return The return value of the function. + @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback +]] +local function peek(fn: (Args...) -> Results..., ...: Args...): Results... + if + activeSub + and bit32.band(activeSub.flags, PEEKING) == 0 + and bit32.btest(activeSub.flags, bit32.bor(WATCHING, MUTABLE)) + then + activeSub.flags = bit32.bor(activeSub.flags, PEEKING) + local results: { any } = { pcall(noYield(fn), ...) } + activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(PEEKING)) + if not results[1] then + error(results[2]) + end return unpack(results, 2) else return fn(...) @@ -760,66 +600,73 @@ end Combines multiple updates into a single "commit" that runs effects and computed signals after the provided callback finishes running. - @param fn Function that updates signals - @param ...args Arguments to pass to the function - @return Values returned by the function + @param fn A function that performs multiple updates. + @param ...args Arguments to pass to the function. + @return The return value of the function. @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback ]] local function batch(fn: (Args...) -> Results..., ...: Args...): Results... startBatch() - local results: { any } = { pcall(wrapUserSpace(fn), ...) } + local results: { any } = { pcall(noYield(fn), ...) } endBatch() - if not results[1] then - error(results[2], 2) + error(results[2]) end - return unpack(results, 2) end --[[ - Returns true if the given function is a signal getter, computed signal, or - atom function. Used for determining whether a function should be wrapped in - a computed signal so that it can be memoized in `listen()` and `subscribe()`. + Converts a non-signal into a computed signal. If the function is already a + signal, it is returned as-is. Useful for memoizing a getter function + without creating unnecessary nested signals. + + @param fn A function that returns the value to track. + @return A computed signal that tracks the value returned by the getter. ]] -local function isSignal(fn: Getter): boolean +local function toSignal(fn: () -> T): () -> T local name = debug.info(fn, "n") - return name == "signalGetter" or name == "computedOper" or name == "atomOper" + return if name == "readSignal" or name == "readComputed" or name == "atomOper" then fn else computed(fn) end --[[ - Creates an effect that runs the callback once immediately, and then again - when the value returned by the getter changes. The callback receives the - new value and the previous value as arguments. + Creates an effect that runs the callback one immediately, and then again + when the value returned by the getter changes. The callback receives the new + value and the previous value as arguments. - @param getter A signal or function that accesses signals - @param callback Function to run when the value changes - @return A cleanup function that stops the effect + 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. + @return A function for disposing the effect. @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback ]] -local function listen(getter: Getter, callback: (state: T, lastState: T?) -> ()): Cleanup - local tracked: Getter = if isSignal(getter) then getter else computed(getter :: (T?) -> T) +local function listen(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> () + local tracked = toSignal(getter) local current: T return effect(function() local previous = current current = tracked() - untracked(callback, current, previous) + peek(fn, current, previous) end) end --[[ - 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. + 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. - @param getter A signal or function that accesses signals - @param callback Function to run when the value changes - @return A cleanup function that stops the effect + 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. + @return A function for disposing the effect. @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback ]] -local function subscribe(getter: Getter, callback: (state: T, lastState: T) -> ()): Cleanup - local tracked: Getter = if isSignal(getter) then getter else computed(getter :: (T?) -> T) +local function subscribe(getter: () -> T, fn: (state: T, lastState: T) -> ()): () -> () + local tracked = toSignal(getter) local current: T local firstRun = true @@ -829,68 +676,91 @@ local function subscribe(getter: Getter, callback: (state: T, lastState: T if firstRun then firstRun = false else - untracked(callback, current, previous) + peek(fn, current, previous) end end) end --[[ - Calls the callback function for each unique key in the table returned by - the getter. The callback is called once for each key, and again when a new - key is added. + Disables recursive checks for the current effect or computed signal. Useful + for effects that intentionally update signals they depend on. + + @example + ```luau + effect(function() + recursive() + setCount(getCount() + 1) + end) + ``` +]] +local function recursive() + if activeSub then + activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(RECURSED_CHECK)) + else + warn(debug.traceback("recursive() can only be called inside an effect or computed signal.", 2)) + 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. The callback may return a cleanup function, which will be called when the key is removed or the `observe()` effect is disposed. Effects created during the callback will also be bound to the lifetime of the key. - @param getter A signal or function that returns a table - @param callback Function that is called for each key added to the table - @return A cleanup function that disposes scopes for all keys + @param getter A function that returns the table to observe. + @param fn A function that is called for each key in the table. + @return A function for disposing the observer. @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback ]] -local function observe(getter: Getter<{ [K]: V }>, callback: (item: V, key: K) -> ...Cleanup?): Cleanup - getter = wrapUserSpace(getter) - callback = wrapUserSpace(callback) - - local scopes: { [K]: Cleanup } = {} - local stopObserving = false +local function observe(getter: () -> { [K]: V }, fn: (item: V, key: K) -> ...(() -> ())?): () -> () + local innerScopes: { [K]: () -> () } = {} + local disposed = false local function updateScopes(data: { [K]: V }) - for key, dispose in scopes do + for key, dispose in innerScopes do if data[key] == nil then - scopes[key] = nil + innerScopes[key] = nil dispose() - if stopObserving then + if disposed then return end end end for key, value in data do - if scopes[key] == nil then - local dispose = effectScope(function() - return callback(value, key) + if innerScopes[key] == nil then + local disposeScope = effectScope(function() + return fn(value, key) end, true) - if stopObserving then - dispose() - return + if disposed then + return disposeScope() end - scopes[key] = dispose + innerScopes[key] = disposeScope end end end return effectScope(function() effect(function() - -- Wrap in untracked so that calling fn inside updateScopes does - -- not register dependencies to this effect - untracked(updateScopes, getter()) + -- Allow recursive updates in case the callback creates or removes + -- items in the observed table. + recursive() + -- Batch so that side effects run after all scopes are created. + -- This ensures that a scope creating or removing items doesn't + -- interfere with the current update cycle. + batch(updateScopes, getter()) end) return function() - stopObserving = true - for key, dispose in scopes do - scopes[key] = nil + if disposed then + return + end + disposed = true + for key, dispose in innerScopes do + innerScopes[key] = nil dispose() end end @@ -899,102 +769,249 @@ end --[[ Creates a new read-only signal that is computed by mapping over the entries - of the source table. If the key is omitted from the transform function's - return value, the original key is preserved. + of an existing atom. If the key is omitted from the mapper's return value, + the original key is preserved. - This function is optimized to minimize unnecessary updates by only calling - the transform function for entries that have changed since the last run. - - @param getter A signal or function that returns a table - @param transform Function that maps each entry from the source to a new entry in the mapped table - @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform + @param getter Returns the table to map over. + @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. + @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper ]] -local function mapped(getter: Getter<{ [KI]: VI }>, transform: (VI, KI) -> (VO, KO)): Getter<{ [KO]: VO }> - getter = wrapUserSpace(getter) - transform = wrapUserSpace(transform) +local function mapped(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } + local currentInput: { [KI]: VI } = {} + local keyCache: { [KI]: KO } = {} - local nextInput: { [KI]: VI } = {} - local keyMap: { [KI]: KO } = {} + local function map(value: VI, key: KI): (VO, KO) + local prevSub = setActiveSub(nil) + local success, valueOut, keyOut = pcall(mapper, value, key) + activeSub = prevSub + if not success then + error(`mapped() transform function failed: {valueOut}`) + end + return valueOut, if keyOut == nil then key else keyOut :: any + end - return computed(function(lastOutput: { [KO]: VO }?): { [KO]: VO } - local nextOutput: { [KO]: VO } = lastOutput or {} - local prevInput = nextInput - nextInput = getter() + return computed(function(previousOutput) + local currentOutput: { [KO]: VO } = previousOutput or {} + local previousInput = currentInput + currentInput = getter() - -- For keys that were removed from the input, remove the corresponding - -- keys from the output. - for keyIn in prevInput do - if nextInput[keyIn] ~= nil then + for keyIn in previousInput do + if currentInput[keyIn] ~= nil then continue end - local keyOut = keyMap[keyIn] + local prevKeyOut = keyCache[keyIn] - if keyOut ~= nil then - if nextOutput == lastOutput then - nextOutput = table.clone(nextOutput) + if prevKeyOut then + if currentOutput == previousOutput then + currentOutput = table.clone(currentOutput) end - nextOutput[keyOut] = nil - keyMap[keyIn] = nil + currentOutput[prevKeyOut] = nil + keyCache[keyIn] = nil end end - -- For keys that are new or changed, pass it to the mapper and update - -- the output. If the mapper returns a new key that is different from - -- the previous call, the old mapped key should be removed from the - -- output. - for keyIn, nextValueIn in nextInput do - if nextValueIn == prevInput[keyIn] then + for keyIn, valueIn in currentInput do + if valueIn == previousInput[keyIn] then continue end - local nextValueOut, keyOutOrNil = transform(nextValueIn, keyIn) - local nextKeyOut: KO = if keyOutOrNil ~= nil then keyOutOrNil else keyIn - local prevKeyOut = keyMap[keyIn] + local valueOut, keyOut = map(valueIn, keyIn) - if prevKeyOut ~= nil and prevKeyOut ~= nextKeyOut then - if nextOutput == lastOutput then - nextOutput = table.clone(nextOutput) + if currentOutput[keyOut] ~= valueOut then + if currentOutput == previousOutput then + currentOutput = table.clone(currentOutput) end - nextOutput[prevKeyOut] = nil - nextOutput[nextKeyOut] = nextValueOut - keyMap[keyIn] = nextKeyOut - elseif nextOutput[nextKeyOut] ~= nextValueOut then - if nextOutput == lastOutput then - nextOutput = table.clone(nextOutput) - end - nextOutput[nextKeyOut] = nextValueOut - keyMap[keyIn] = nextKeyOut + currentOutput[keyOut] = valueOut + keyCache[keyIn] = keyOut end end - return nextOutput + return currentOutput end) end -createReactiveSystem(update, notify, unwatched) +--[[ + Determines if the provided value is a reactive proxy created by `reactive()`. + + @param value A value to test. + @return `true` if the value is a reactive proxy, `false` otherwise. + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function isReactive(value: any): boolean + return type(value) == "table" and getmetatable(value) == ReactiveProxy +end + +--[[ + Unwraps a reactive proxy to get the original table. If the provided value + is not a proxy, it is returned as-is. + + @param value A reactive proxy or a non-proxy value. + @return The unwrapped target value. + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function toRaw(value: T): T + if isReactive(value) then + local value = value :: ReactiveProxy & T + value.__track() + return toRaw(value.__target) + end + return value +end + +--[[ + Creates a deep reactive value that tracks properties when they're accessed. + Returns a proxy object that subscribes to changes on any nested property + you read, and a function for mutating the original table. + + @example + ```luau + local users, setUsers = reactive({ + { name = "John", surname = "Doe" }, + }) + + effect(function() + print(`{users[1].name} {users[1].surname}`) + end) + + setUsers(function(state) + state[1].name = "Jane" + state[1].surname = "Smith" + end) + ``` + + @param initialValue The value to make deeply reactive. + @return A proxy table that tracks reads to nested properties. + @return A function for updating the original table. + @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue +]] +local function reactive(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ()) + initialValue = toRaw(initialValue) + + local proxy = ReactiveProxy.proxyMap[initialValue] + + if not proxy then + local track = computed(function(version: number?) + -- Reference the proxy to avoid GCing while an effect is actively + -- tracking this reactive object. + local _ref = proxy + -- Allow updates from the parent to propagate down when the + -- target is mutated directly through the setter. + if trackParent then + trackParent() + end + return (version or 0) + 1 + end) + + proxy = setmetatable({ + __target = initialValue, + __signals = setmetatable({}, { __mode = "v" }) :: any, + __track = track, + }, ReactiveProxy) + + ReactiveProxy.proxyMap[initialValue] = proxy + end + + local function setValue(update: ((T) -> ())?) + if update then + update(proxy.__target) + end + trigger(proxy.__track) + end + + return proxy :: any, setValue +end + +--[[ + Wraps a value in `reactive()` if it is a table; otherwise, returns the + value as-is. + + @param value A value to make reactive. + @param trackParent An optional tracking function to propagate deep updates. + @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 +end + +function ReactiveProxy.__index(self: ReactiveProxy, key: any): any + local track = self.__track + local signals = self.__signals + local target = self.__target + + if activeSub then + if not signals[key] then + signals[key] = computed(function() + track() + return target[key] + end) + end + signals[key]() + end + + return toReactive(target[key], track) +end + +function ReactiveProxy.__newindex(self: ReactiveProxy, key: any, value: any) + value = toRaw(value) + if self.__target[key] ~= value then + self.__target[key] = value + trigger(self.__track) + end +end + +function ReactiveProxy.__iter(self: ReactiveProxy) + self.__track() + local function nextReactive(target: any, prevKey: any) + local nextKey, nextValue = next(target, prevKey) + return nextKey, toReactive(nextValue, self.__track) + end + return nextReactive, self.__target +end + +function ReactiveProxy.__len(self: ReactiveProxy) + self.__track() + return #self.__target +end + +local reactiveSystem = system.createReactiveSystem({ + update = update, + notify = notify, + unwatched = unwatched, +}) + +link = reactiveSystem.link +unlink = reactiveSystem.unlink +propagate = reactiveSystem.propagate +checkDirty = reactiveSystem.checkDirty +shallowPropagate = reactiveSystem.shallowPropagate return { ReactiveFlags = system.ReactiveFlags, flags = flags, - atom = atom, + getActiveSub = getActiveSub, + setActiveSub = setActiveSub, + startBatch = startBatch, + endBatch = endBatch, signal = signal, computed = computed, effect = effect, effectScope = effectScope, trigger = trigger, + atom = atom, + onCleanup = onCleanup, untracked = untracked, + peek = peek, batch = batch, subscribe = subscribe, listen = listen, + recursive = recursive, observe = observe, mapped = mapped :: (( - getter: Getter<{ [KI]: VI }>, - transform: (VI, KI) -> (VO, KO) - ) -> Getter<{ [KO]: VO }>) & ((getter: Getter<{ [K]: VI }>, transform: (VI, K) -> VO) -> Getter<{ [K]: VO }>), - onCleanup = onCleanup, - getActiveSub = getActiveSub, - setActiveSub = setActiveSub, - startBatch = startBatch, - endBatch = endBatch, + getter: () -> { [KI]: VI }, + mapper: (item: VI, key: KI) -> (VO, KO) + ) -> () -> { [KO]: VO }) & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }), + reactive = reactive, + isReactive = isReactive, + toRaw = toRaw, } diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau index a01aa5e..565419e 100644 --- a/packages/charm/src/system.luau +++ b/packages/charm/src/system.luau @@ -1,6 +1,28 @@ --[[ Reactive system adapted from alien-signals at https://github.com/stackblitz/alien-signals/blob/master/src/system.ts + + MIT License + + Copyright (c) 2024-present Johnson Chu + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. ]] export type ReactiveNode = { @@ -28,15 +50,16 @@ type Stack = { export type ReactiveFlags = number -local NONE = 0b000000 -local MUTABLE = 0b000001 -local WATCHING = 0b000010 -local RECURSED_CHECK = 0b000100 -local RECURSED = 0b001000 -local DIRTY = 0b010000 -local PENDING = 0b100000 +local NONE = 0b0000000 +local MUTABLE = 0b0000001 +local WATCHING = 0b0000010 +local RECURSED_CHECK = 0b0000100 +local RECURSED = 0b0001000 +local DIRTY = 0b0010000 +local PENDING = 0b0100000 +local PEEKING = 0b1000000 -local ReactiveFlags = table.freeze({ +local ReactiveFlags = { None = NONE, Mutable = MUTABLE, Watching = WATCHING, @@ -44,160 +67,161 @@ local ReactiveFlags = table.freeze({ Recursed = RECURSED, Dirty = DIRTY, Pending = PENDING, +} + +local function createReactiveSystem(props: { + update: (sub: ReactiveNode) -> boolean, + notify: (sub: ReactiveNode) -> (), + unwatched: (sub: ReactiveNode) -> (), }) + local update = props.update + local notify = props.notify + local unwatched = props.unwatched -local update: (sub: ReactiveNode) -> boolean -local notify: (sub: ReactiveNode) -> () -local unwatched: (sub: ReactiveNode) -> () - -local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean - local link = sub.depsTail - while link do - if link == checkLink then - return true + local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean + local link = sub.depsTail + while link do + if link == checkLink then + return true + end + link = link.prevDep end - link = link.prevDep + return false end - return false -end -local function link(dep: ReactiveNode, sub: ReactiveNode, version: number) - local prevDep = sub.depsTail - if prevDep and prevDep.dep == dep then - return - end - local nextDep = if prevDep then prevDep.nextDep else sub.deps - if nextDep and nextDep.dep == dep then - nextDep.version = version - sub.depsTail = nextDep - return - end - local prevSub = dep.subsTail - if prevSub and prevSub.version == version and prevSub.sub == sub then - return - end - local newLink: Link = { - version = version, - dep = dep, - sub = sub, - prevDep = prevDep, - nextDep = nextDep, - prevSub = prevSub, - } - sub.depsTail = newLink - dep.subsTail = newLink - if nextDep then - nextDep.prevDep = newLink - end - if prevDep then - prevDep.nextDep = newLink - else - sub.deps = newLink - end - if prevSub then - prevSub.nextSub = newLink - else - dep.subs = newLink - end -end - -local function unlink(link: Link, sub: ReactiveNode?): Link? - sub = sub or link.sub - local dep = link.dep - local prevDep = link.prevDep - local nextDep = link.nextDep - local nextSub = link.nextSub - local prevSub = link.prevSub - if nextDep then - nextDep.prevDep = prevDep - else - sub.depsTail = prevDep - end - if prevDep then - prevDep.nextDep = nextDep - else - sub.deps = nextDep - end - if nextSub then - nextSub.prevSub = prevSub - else - dep.subsTail = prevSub - end - if prevSub then - prevSub.nextSub = nextSub - else - dep.subs = nextSub - if not nextSub then - unwatched(dep) + local function link(dep: ReactiveNode, sub: ReactiveNode, version: number) + -- DEVIATION: Do not link signals to peeking subscribers + if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then + return end - end - return nextDep -end - -local function propagate(link: Link) - repeat - local sub = link.sub - local flags = sub.flags - - if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then - sub.flags = bit32.bor(flags, PENDING) - elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then - flags = NONE - elseif bit32.band(flags, RECURSED_CHECK) == 0 then - sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING) - elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 and isValidLink(link, sub) then - sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING)) - flags = bit32.band(flags, MUTABLE) + local prevDep = sub.depsTail + if prevDep and prevDep.dep == dep then + return + end + local nextDep = if prevDep then prevDep.nextDep else sub.deps + if nextDep and nextDep.dep == dep then + nextDep.version = version + sub.depsTail = nextDep + return + end + local prevSub = dep.subsTail + if prevSub and prevSub.version == version and prevSub.sub == sub then + return + end + local newLink: Link = { + version = version, + dep = dep, + sub = sub, + prevDep = prevDep, + nextDep = nextDep, + prevSub = prevSub, + } + sub.depsTail = newLink + dep.subsTail = newLink + if nextDep then + nextDep.prevDep = newLink + end + if prevDep then + prevDep.nextDep = newLink else - flags = NONE + sub.deps = newLink end - - if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then - notify(sub) + if prevSub then + prevSub.nextSub = newLink + else + dep.subs = newLink end + end - if bit32.btest(flags, MUTABLE) then - local subSubs = sub.subs - if subSubs then - propagate(subSubs) + local function unlink(link: Link, sub: ReactiveNode?): Link? + local sub = sub or link.sub + local dep = link.dep + local prevDep = link.prevDep + local nextDep = link.nextDep + local nextSub = link.nextSub + local prevSub = link.prevSub + if nextDep then + nextDep.prevDep = prevDep + else + sub.depsTail = prevDep + end + if prevDep then + prevDep.nextDep = nextDep + else + sub.deps = nextDep + end + if nextSub then + nextSub.prevSub = prevSub + else + dep.subsTail = prevSub + end + if prevSub then + prevSub.nextSub = nextSub + else + dep.subs = nextSub + if not nextSub then + unwatched(dep) end end + return nextDep + end - link = link.nextSub :: Link - until not link -end + local function propagate(link: Link) + repeat + local sub = link.sub + local flags = sub.flags + + if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then + sub.flags = bit32.bor(flags, PENDING) + elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then + flags = NONE + elseif bit32.band(flags, RECURSED_CHECK) == 0 then + sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING) + elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 and isValidLink(link, sub) then + sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING)) + flags = bit32.band(flags, MUTABLE) + else + -- selene: allow(if_same_then_else) + flags = NONE + end -local function shallowPropagate(link: Link) - repeat - local sub = link.sub - local flags = sub.flags - if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then - sub.flags = bit32.bor(flags, DIRTY) if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then notify(sub) end - end - link = link.nextSub :: Link - until not link -end -local function checkDirty(link: Link, sub: ReactiveNode): boolean - repeat - local dep = link.dep - local depFlags = dep.flags - - if bit32.btest(sub.flags, DIRTY) then - return true - elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then - if update(dep) then - local subs = dep.subs :: Link - if subs.nextSub then - shallowPropagate(subs) + if bit32.btest(flags, MUTABLE) then + local subSubs = sub.subs + if subSubs then + propagate(subSubs) end - return true end - elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then - if checkDirty(dep.deps :: Link, dep) then + + link = link.nextSub :: Link + until not link + end + + local function shallowPropagate(link: Link) + repeat + local sub = link.sub + local flags = sub.flags + if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then + sub.flags = bit32.bor(flags, DIRTY) + if bit32.btest(flags, WATCHING) then + notify(sub) + end + end + link = link.nextSub :: Link + until not link + end + + local function checkDirty(link: Link, sub: ReactiveNode): boolean + repeat + local dep = link.dep + local depFlags = dep.flags + + if bit32.btest(sub.flags, DIRTY) then + return true + elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then if update(dep) then local subs = dep.subs :: Link if subs.nextSub then @@ -205,33 +229,36 @@ local function checkDirty(link: Link, sub: ReactiveNode): boolean end return true end - else - dep.flags = bit32.band(depFlags, bit32.bnot(PENDING)) + elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then + if checkDirty(dep.deps :: Link, dep) then + if update(dep) then + local subs = dep.subs :: Link + if subs.nextSub then + shallowPropagate(subs) + end + return true + end + else + dep.flags = bit32.band(depFlags, bit32.bnot(PENDING)) + end end - end - link = link.nextDep :: Link - until not link + link = link.nextDep :: Link + until not link - return false -end + return false + end -local function createReactiveSystem( - updateFn: (sub: ReactiveNode) -> boolean, - notifyFn: (sub: ReactiveNode) -> (), - unwatchedFn: (sub: ReactiveNode) -> () -) - update = updateFn - notify = notifyFn - unwatched = unwatchedFn + return { + link = link, + unlink = unlink, + propagate = propagate, + checkDirty = checkDirty, + shallowPropagate = shallowPropagate, + } end return { ReactiveFlags = ReactiveFlags, - link = link, - unlink = unlink, - propagate = propagate, - checkDirty = checkDirty, - shallowPropagate = shallowPropagate, createReactiveSystem = createReactiveSystem, } diff --git a/packages/charm/test/computed.test.luau b/packages/charm/test/computed.test.luau deleted file mode 100644 index c0095cf..0000000 --- a/packages/charm/test/computed.test.luau +++ /dev/null @@ -1,220 +0,0 @@ -local Charm = require("../src") -local test = require("../../../tests/test") - -local atom = Charm.atom -local computed = Charm.computed -local effect = Charm.effect -local signal = Charm.signal - -test("should correctly propagate changes through computed core", function() - local src, setSrc = signal(0) - local c1 = computed(function() - return src() % 2 - end) - local c2 = computed(function() - return c1() - end) - local c3 = computed(function() - return c2() - end) - - c3() - setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty - c2() -- c1 -> none, c2 -> none - setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty - - assert(c3() == 1, "bad final value") -end) - -test("should propagate updated source value through chained computations", function() - local src, setSrc = signal(0) - local a = computed(function() - return src() - end) - local b = computed(function() - return a() % 2 - end) - local c = computed(function() - return src() - end) - local d = computed(function() - return b() + c() - end) - - assert(d() == 0, "bad initial value") - setSrc(2) - assert(d() == 2, "bad updated value") -end) - -test("should handle flags are indirectly updated during checkDirty", function() - local a, setA = signal(false) - local b = computed(function() - return a() - end) - local c = computed(function() - b() - return 0 - end) - local d = computed(function() - c() - return b() - end) - - assert(d() == false, "bad initial value") - setA(true) - assert(d() == true, "bad updated value") -end) - -test("should not update if the signal value is reverted", function() - local times = 0 - - local src, setSrc = signal(0) - local c1 = computed(function() - times += 1 - return src() - end) - c1() - assert(times == 1, "computed did not run") - setSrc(1) - setSrc(0) - c1() - assert(times == 1, "computed ran when it should not") -end) - -test("should receive previous value", function() - local calls = {} - local src, setSrc = signal(0) - 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) - - effect(function() - table.insert(calls, c1()) - end) - - setSrc(2) - setSrc(3) - assert(c1() == 2, "should skip odd value") - - setSrc(4) - setSrc(5) - assert(c1() == 4, "should skip odd value") - assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`) -end) - --- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L875 -test("should chained computeds keep reactivity when computed effect happens", function() - local v = atom("Hello") - local c = computed(function() - v(v() .. " World") - return v() - end) - local d = computed(function() - return c() - end) - local e = computed(function() - return d() - end) - local res = computed(function() - return d() .. " | " .. e() - end) - - assert(res() == "Hello World | Hello World", res()) - - v(v() .. " World") - assert(res() == "Hello World World World World | Hello World World World World", res()) -end) - --- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L580 -test("should not trigger effect scheduler by recursive computed effect", function() - local v = atom("Hello") - local c = computed(function() - v(v() .. " World") - return v() - end) - local comp = computed(function() - return c() - end) - - assert(comp() == "Hello World", comp()) - - v(v() .. " World") - assert(comp() == "Hello World World World World", comp()) -end) - --- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L528 -test("should not override queried MaybeDirty result", function() - local v1 = atom(nil :: any) - local v2 = atom(false) - local c1 = computed(function() - local c = v1() - if not v1() then - c = { v = atom(0) } - v1(c) - end - return c.v() - end) - local c2 = computed(function() - if not v2() then - return "no" - end - return if c1() then "yes" else "no" - end) - local c3 = computed(function() - return c2() - end) - - c3() - v2(true) - - c3() - v1().v(999) - - assert(c3() == "yes", "should return correct value after update") -end) - --- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L503 -test("should trigger effect even computed already dirty", function() - local calls: { string } = {} - local v = atom(0) - local c1 = computed(function() - if v() == 0 then - v(1) - end - return "foo" - end) - local c2 = computed(function() - return v() .. c1() - end) - - effect(function() - table.insert(calls, c2()) - end) - assert(table.concat(calls, ",") == "0foo", table.concat(calls, ",")) - assert(v() == 1, `expected 1 for v(), got {v()}`) - v(2) - assert(table.concat(calls, ",") == "0foo,2foo", table.concat(calls, ",")) - assert(v() == 2, `expected 2 for v(), got {v()}`) -end) - -test("should handle inner effect clearing outer computed's deps on rerun", function() - local src, setSrc = signal(false) - local stopEffect: () -> () - local c = computed(function() - src() - effect(function() - return function() - stopEffect() - end - end) - effect(function() end) - return 0 - end) - stopEffect = effect(function() - c() - end) - setSrc(true) -end) - -return {} diff --git a/packages/charm/test/errors.test.luau b/packages/charm/test/errors.test.luau deleted file mode 100644 index 151a0d5..0000000 --- a/packages/charm/test/errors.test.luau +++ /dev/null @@ -1,200 +0,0 @@ ---!optimize 1 - -local Charm = require("../src") -local test = require("../../../tests/test") - -test("should disallow yielding", function() - local success = pcall(function() - Charm.effectScope(function() - coroutine.yield() - end) - end) - assert(not success, "should error on yield") -end) - -test.describe("traceback", function() - local DEFAULT_ERROR = "Test error" - - local function fail(message: string?) - error(message or DEFAULT_ERROR, 0) - end - - local function indirectError(message: string?) - fail(message) - end - - local function assertStackTrace(err: unknown, expectation: string?) - expectation = expectation or DEFAULT_ERROR - assert(type(err) == "string", "Expected error to be a string") - assert(string.find(err, expectation), `Expected error message to be "{expectation}", got: {err}`) - assert(string.find(err, "fail"), `Expected stack trace to contain fail, got: {err}`) - assert(string.find(err, "indirectError"), `Expected stack trace to contain indirectError, got: {err}`) - end - - test("signal update", function() - local _, err = pcall(function() - local _, setSrc = Charm.signal(0) - setSrc(function() - indirectError() - return 1 - end) - end) - assertStackTrace(err) - end) - - test("atom update", function() - local _, err = pcall(function() - local src = Charm.atom(0) - src(function() - indirectError() - return 1 - end) - end) - assertStackTrace(err) - end) - - test("computed", function() - local _, err = pcall(function() - local src = Charm.computed(function() - indirectError() - end) - src() - end) - assertStackTrace(err) - end) - - test("effect init", function() - local _, err = pcall(function() - Charm.effect(function() - indirectError() - end) - end) - assertStackTrace(err) - end) - - test("effect run", function() - local _, err = pcall(function() - local src, setSrc = Charm.signal(0) - Charm.effect(function() - if src() == 1 then - indirectError() - end - end) - setSrc(1) - end) - assertStackTrace(err) - end) - - test("effect cleanups", function() - local _, err = pcall(function() - local dispose = Charm.effect(function() - Charm.onCleanup(function() - indirectError("onCleanup") - end) - return function() - indirectError("returned cleanup") - end - end) - dispose() - end) - assertStackTrace(err, "onCleanup") - assertStackTrace(err, "returned cleanup") - end) - - test("trigger", function() - local _, err = pcall(function() - Charm.trigger(function() - indirectError() - end) - end) - assertStackTrace(err) - end) - - test("untracked", function() - local _, err = pcall(function() - Charm.effectScope(function() - Charm.untracked(function() - indirectError() - end) - end) - end) - assertStackTrace(err) - end) - - test("batch", function() - local _, err = pcall(function() - Charm.batch(function() - indirectError() - end) - end) - assertStackTrace(err) - end) - - test("subscribe getter", function() - local _, err = pcall(function() - Charm.subscribe(function() - indirectError() - end, function() end) - end) - assertStackTrace(err) - end) - - test("subscribe callback", function() - local _, err = pcall(function() - local src, setSrc = Charm.signal(0) - Charm.subscribe(src, function() - indirectError() - end) - setSrc(1) - end) - assertStackTrace(err) - end) - - test("listen getter", function() - local _, err = pcall(function() - Charm.listen(function() - indirectError() - end, function() end) - end) - assertStackTrace(err) - end) - - test("listen callback", function() - local _, err = pcall(function() - local src, setSrc = Charm.signal(0) - Charm.listen(src, function() - indirectError() - end) - setSrc(1) - end) - assertStackTrace(err) - end) - - test("mapped getter", function() - local _, err = pcall(function() - local src = Charm.mapped(function() - indirectError() - return { 1 } - end, function() - return 1 - end) - src() - end) - assertStackTrace(err) - end) - - test("mapped transform", function() - local _, err = pcall(function() - local src = Charm.mapped(function() - return { 1 } - end, function() - indirectError() - return 1 - end) - src() - end) - assertStackTrace(err) - end) -end) - -return {} diff --git a/packages/charm/test/init.luau b/packages/charm/test/init.luau deleted file mode 100644 index 52da435..0000000 --- a/packages/charm/test/init.luau +++ /dev/null @@ -1,55 +0,0 @@ -local test = require("../../tests/test") - -test.describe("computed", function() - require("@self/computed.test") -end) - -test.describe("effect", function() - require("@self/effect.test") -end) - -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) - -test.describe("listen", function() - require("@self/listen.test") -end) - -test.describe("mapped", function() - require("@self/mapped.test") -end) - -test.describe("observe", function() - require("@self/observe.test") -end) - -test.describe("signal", function() - require("@self/signal.test") -end) - -test.describe("subscribe", function() - require("@self/subscribe.test") -end) - -test.describe("topology", function() - require("@self/topology.test") -end) - -test.describe("trigger", function() - require("@self/trigger.test") -end) - -test.describe("untracked", function() - require("@self/untracked.test") -end) - -return {} diff --git a/packages/charm/wally.toml b/packages/charm/wally.toml index 25eb0a5..482bcf6 100644 --- a/packages/charm/wally.toml +++ b/packages/charm/wally.toml @@ -1,8 +1,8 @@ [package] name = "littensy/charm" -version = "0.11.1" +version = "0.11.0-rc.3" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] +exclude = ["node_modules", "src/__tests__", "src/index.d.ts", "package.json"] [dependencies] diff --git a/packages/deep-charm/Charm.luau b/packages/deep-charm/Charm.luau deleted file mode 100644 index 619a57a..0000000 --- a/packages/deep-charm/Charm.luau +++ /dev/null @@ -1,5 +0,0 @@ -if not script then - return require("../charm/src") -end - -return require(script.Parent.Parent.charm.src) diff --git a/packages/deep-charm/default.project.json b/packages/deep-charm/default.project.json deleted file mode 100644 index 1937acb..0000000 --- a/packages/deep-charm/default.project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "deep-charm", - "tree": { - "$path": "src" - } -} diff --git a/packages/deep-charm/package.json b/packages/deep-charm/package.json deleted file mode 100644 index 081b711..0000000 --- a/packages/deep-charm/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@rbxts/deep-charm", - "version": "0.1.1", - "description": "Use Charm with plain Luau tables", - "license": "MIT", - "main": "src/init.luau", - "types": "src/index.d.ts", - "files": [ - "src", - "*.luau", - "!wally.toml", - "!wally.lock", - "!default.project.json", - "!test" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/littensy/charm.git" - }, - "scripts": { - "prepare": "cp ../../LICENSE . && cp ../../README.md .", - "publish:wally": "wally publish" - }, - "dependencies": { - "@rbxts/charm": "workspace:^" - } -} diff --git a/packages/deep-charm/src/index.d.ts b/packages/deep-charm/src/index.d.ts deleted file mode 100644 index dc1aa58..0000000 --- a/packages/deep-charm/src/index.d.ts +++ /dev/null @@ -1,53 +0,0 @@ -export type ReactiveProxy = T & { - __track: () => void; - __target: T; - __signals: Map unknown>; -}; - -/** - * Creates a deep reactive value that tracks properties when they're accessed. - * Returns a proxy object that subscribes to changes on any nested property - * you read, and a function for mutating the original table. - * - * @example - * ```luau - * local users, updateUsers = reactive({ - * user = { name = "John", surname = "Doe" }, - * }) - * - * effect(function() - * print(users.user and `{users.user.name} {users.user.surname}`) - * end) - * - * updateUsers(function(state) - * state.user.name = "Jane" - * state.user.surname = "Smith" - * end) - * ``` - * - * @param initialValue The value to make deeply reactive. - * @returns A proxy table and a function to mutate the original table. - * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue - */ -export function reactive( - initialValue: T, -): LuaTuple<[value: T, update: (update: T | ((initialValue: T) => void)) => T]>; - -/** - * Determines if the provided value is a reactive proxy created by `reactive()`. - * - * @param value A value to test. - * @returns `true` if the value is a reactive proxy, `false` otherwise. - * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue - */ -export function isReactive(value: unknown): boolean; - -/** - * Unwraps a reactive proxy to get the original table. If the provided value - * is not a proxy, it is returned as-is. - * - * @param value A reactive proxy or a non-proxy value. - * @returns The unwrapped target value. - * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue - */ -export function toRaw(value: T): T; diff --git a/packages/deep-charm/src/init.luau b/packages/deep-charm/src/init.luau deleted file mode 100644 index 9e16cb1..0000000 --- a/packages/deep-charm/src/init.luau +++ /dev/null @@ -1,165 +0,0 @@ -local Charm = require("./Charm") - -local computed = Charm.computed -local trigger = Charm.trigger -local getActiveSub = Charm.getActiveSub - -export type ReactiveProxy = typeof(setmetatable( - {} :: { - __track: () -> (), - __target: T, - __signals: { [unknown]: () -> unknown }, - }, - {} :: { __index: T } -)) - -local ReactiveProxy = { - proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }), -} - ---[[ - Determines if the provided value is a reactive proxy created by `reactive()`. - - @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue -]] -local function isReactive(value: any): boolean - return type(value) == "table" and getmetatable(value) == ReactiveProxy -end - ---[[ - Unwraps a reactive proxy to get the original table. If the provided value - is not a proxy, it is returned as-is. - - @param value A reactive proxy or a non-proxy value - @return The source table if `value` is a reactive proxy - @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue -]] -local function toRaw(value: T): T - if isReactive(value) then - local proxy = value :: ReactiveProxy - proxy.__track() - return toRaw(proxy.__target) - end - return value -end - ---[[ - Creates a deep reactive value that tracks properties when they're accessed. - Returns a proxy object that subscribes to changes on any nested property - you read, and a function for mutating the original table. - - @example - ```luau - local users, updateUsers = reactive({ - { name = "John", surname = "Doe" }, - }) - - effect(function() - print(`{users[1].name} {users[1].surname}`) - end) - - updateUsers(function(state) - state[1].name = "Jane" - state[1].surname = "Smith" - end) - ``` - - @param initialValue Source table to make deeply reactive - @return A proxy table that tracks reads to nested properties - @return A function for batching mutations to the source table - @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue -]] -local function reactive(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ()) - initialValue = toRaw(initialValue) - - local proxy = ReactiveProxy.proxyMap[initialValue] - - if not proxy then - local track = computed(function(version: number?) - -- Reference the proxy to avoid GCing while an effect is actively - -- tracking this reactive object. - local _ref = proxy - -- Allow updates from the parent to propagate down when the - -- target is mutated directly through the setter. - if trackParent then - trackParent() - end - return (version or 0) + 1 - end) - - proxy = setmetatable({ - __target = initialValue, - __signals = setmetatable({}, { __mode = "v" }) :: any, - __track = track, - }, ReactiveProxy) - - ReactiveProxy.proxyMap[initialValue] = proxy - end - - local function setValue(update: ((T) -> ())?) - if update then - update(proxy.__target) - end - trigger(proxy.__track) - end - - return proxy :: any, setValue -end - ---[[ - Wraps a value in `reactive()` if it is a table; otherwise, returns the - value as-is. - - @param value Source value to make deeply reactive - @param trackParent Optional tracking function to propagate deep updates - @return A reactive proxy if the value is a table -]] -local function toReactive(value: T, trackParent: (() -> ())?): T - return if type(value) == "table" then reactive(value, trackParent) :: T else value :: T -end - -function ReactiveProxy.__index(self: ReactiveProxy, key: any): any - local track = self.__track - local signals = self.__signals - local target = self.__target - - if getActiveSub() then - if not signals[key] then - signals[key] = computed(function() - track() - return target[key] - end) - end - signals[key]() - end - - return toReactive(target[key], track) -end - -function ReactiveProxy.__newindex(self: ReactiveProxy, key: any, value: any) - value = toRaw(value) - if self.__target[key] ~= value then - self.__target[key] = value - trigger(self.__track) - end -end - -function ReactiveProxy.__iter(self: ReactiveProxy) - self.__track() - local function nextReactive(target: any, prevKey: any) - local nextKey, nextValue = next(target, prevKey) - return nextKey, toReactive(nextValue, self.__track) - end - return nextReactive, self.__target -end - -function ReactiveProxy.__len(self: ReactiveProxy) - self.__track() - return #self.__target -end - -return { - reactive = reactive, - isReactive = isReactive, - toRaw = toRaw, -} diff --git a/packages/deep-charm/test/gc.test.luau b/packages/deep-charm/test/gc.test.luau deleted file mode 100644 index 4fe9f3d..0000000 --- a/packages/deep-charm/test/gc.test.luau +++ /dev/null @@ -1,79 +0,0 @@ -local Charm = require("../Charm") -local DeepCharm = require("../src") -local test = require("../../../tests/test") - -type ReactiveProxy = DeepCharm.ReactiveProxy - -local computed = Charm.computed -local effect = Charm.effect -local reactive = DeepCharm.reactive - -local function weakRef(value: T): { value: T? } - return setmetatable({ value = value }, { __mode = "v" }) :: any -end - -local function gc() - for _ = 1, 2000 do - local _ = table.create(100, 0) - end -end - -test("should release inner reactive proxy", function() - local valueRef = weakRef({}) - local src: any = reactive({ value = valueRef.value }) - - local dispose = effect(function() - local _ = src.value -- create proxy - end) - src.value = nil :: any -- release proxy - dispose() - - gc() - assert(src.__signals.value == nil, "should release signals") - assert(valueRef.value == nil, "should release inner proxy") -end) - -test("should release reactive property deps", function() - local src: any = reactive({ foo = 1 }) - - local c = computed(function() - return src.foo - end) - - c() - assert(src.__signals.foo ~= nil, "should have deps for foo") - - c = nil :: any - gc() - assert(src.__signals.foo == nil, "should release deps for foo") -end) - -test("should not release effect for reactive", function() - local src = reactive({ foo = 1 }) - local calls = 0 - - effect(function() - calls += 1 - local _ = src.foo - end) - assert(calls == 1, "should call effect once") - - gc() - src.foo += 1 - assert(calls == 2, "should call effect again") -end) - -test("should not release tracked reactive", function() - local src = reactive({ users = {} :: { [string]: {}? } }) - local user - - effect(function() - user = src.users.user - end) - - gc() - src.users.user = {} - assert(user ~= nil, "src.users.user should not be released") -end) - -return {} diff --git a/packages/deep-charm/test/init.luau b/packages/deep-charm/test/init.luau deleted file mode 100644 index b7123d6..0000000 --- a/packages/deep-charm/test/init.luau +++ /dev/null @@ -1,11 +0,0 @@ -local test = require("../../tests/test") - -test.describe("reactive", function() - require(`@self/reactive.test`) -end) - -test.describe("gc", function() - require(`@self/gc.test`) -end) - -return {} diff --git a/packages/deep-charm/wally.toml b/packages/deep-charm/wally.toml deleted file mode 100644 index 3f2702f..0000000 --- a/packages/deep-charm/wally.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "littensy/deep-charm" -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" diff --git a/packages/react-charm/Charm.luau b/packages/react-charm/Charm.luau index 49baa5a..9588118 100644 --- a/packages/react-charm/Charm.luau +++ b/packages/react-charm/Charm.luau @@ -1 +1,5 @@ -return require(script.Parent.Parent.charm.src) +local Charm = require(script.Parent.Parent.charm.src) + +export type Atom = Charm.Atom + +return Charm diff --git a/packages/react-charm/React.luau b/packages/react-charm/React.luau index 1c7cfd8..dd59e59 100644 --- a/packages/react-charm/React.luau +++ b/packages/react-charm/React.luau @@ -1 +1,12 @@ +type CoreReactBinding = { + getValue: (self: CoreReactBinding) -> T, + _source: string?, +} + +type ReactBindingMap = { + map: (self: CoreReactBinding & ReactBindingMap, (T) -> U) -> ReactBindingMap & CoreReactBinding, +} + +export type Binding = CoreReactBinding & ReactBindingMap + return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").React) diff --git a/packages/react-charm/package.json b/packages/react-charm/package.json index ca08eca..850b5e6 100644 --- a/packages/react-charm/package.json +++ b/packages/react-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/react-charm", - "version": "0.4.1", + "version": "0.4.0-rc.3", "description": "React bindings for Charm state management", "license": "MIT", "main": "src/init.luau", @@ -11,20 +11,20 @@ "!wally.toml", "!wally.lock", "!default.project.json", - "!test" + "!**/__tests__" ], "repository": { "type": "git", "url": "git+https://github.com/littensy/charm.git" }, "scripts": { - "prepare": "cp ../../LICENSE . && cp ../../README.md .", + "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "publish:wally": "wally publish" }, "dependencies": { - "@rbxts/charm": "workspace:^" + "@rbxts/charm": "workspace:*" }, "peerDependencies": { - "@rbxts/react": ">=17.0.0" + "@rbxts/react": "*" } } diff --git a/packages/react-charm/test/helpers/renderHook.luau b/packages/react-charm/src/__tests__/helpers/renderHook.luau similarity index 78% rename from packages/react-charm/test/helpers/renderHook.luau rename to packages/react-charm/src/__tests__/helpers/renderHook.luau index 661d2e0..6a3ea48 100644 --- a/packages/react-charm/test/helpers/renderHook.luau +++ b/packages/react-charm/src/__tests__/helpers/renderHook.luau @@ -1,5 +1,7 @@ -local React = require("../../React") -local ReactRoblox = require("../../ReactRoblox") +_G.__ROACT_17_MOCK_SCHEDULER__ = true + +local React = require("../../../React") +local ReactRoblox = require("../../../ReactRoblox") type RenderedHook = { result: Result, @@ -13,13 +15,12 @@ local function renderHook(callback: (Props) -> Result, props: Pro local function Component(props: Props) hook.result = callback(props) - return nil end function hook.rerender(newProps: Props?) props = newProps or props ReactRoblox.act(function() - root:render(React.createElement(Component, props) :: any) + root:render(React.createElement(Component, props)) end) end diff --git a/packages/react-charm/src/__tests__/spec.luau b/packages/react-charm/src/__tests__/spec.luau new file mode 100644 index 0000000..69f078a --- /dev/null +++ b/packages/react-charm/src/__tests__/spec.luau @@ -0,0 +1,11 @@ +local suite = require("../../../../test/suite") + +suite.describe("useSignalState", function() + require("./useSignalState.spec") +end) + +suite.describe("useSignalBinding", function() + require("./useSignalBinding.spec") +end) + +return {} diff --git a/packages/react-charm/test/useSignalBinding.test.luau b/packages/react-charm/src/__tests__/useSignalBinding.spec.luau similarity index 89% rename from packages/react-charm/test/useSignalBinding.test.luau rename to packages/react-charm/src/__tests__/useSignalBinding.spec.luau index 8b721ed..1ad6df3 100644 --- a/packages/react-charm/test/useSignalBinding.test.luau +++ b/packages/react-charm/src/__tests__/useSignalBinding.spec.luau @@ -1,7 +1,9 @@ -local Charm = require("../Charm") -local ReactCharm = require("../src") +local Charm = require("../../Charm") +local ReactCharm = require("../") local renderHook = require("./helpers/renderHook") -local test = require("../../../tests/test") +local suite = require("../../../../test/suite") + +local test = suite.test test("tracks signal state", function() local getState, setState = Charm.signal(0) diff --git a/packages/react-charm/test/useSignalState.test.luau b/packages/react-charm/src/__tests__/useSignalState.spec.luau similarity index 88% rename from packages/react-charm/test/useSignalState.test.luau rename to packages/react-charm/src/__tests__/useSignalState.spec.luau index 9618577..ca22e49 100644 --- a/packages/react-charm/test/useSignalState.test.luau +++ b/packages/react-charm/src/__tests__/useSignalState.spec.luau @@ -1,7 +1,9 @@ -local Charm = require("../Charm") -local ReactCharm = require("../src") +local Charm = require("../../Charm") +local ReactCharm = require("../") local renderHook = require("./helpers/renderHook") -local test = require("../../../tests/test") +local suite = require("../../../../test/suite") + +local test = suite.test test("tracks signal state", function() local getState, setState = Charm.signal(0) diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index 08b5342..931a584 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -13,12 +13,15 @@ local React = require("./React") @param dependencies An array of values used to memoize the getter. @return The current state. ]] -local function useSignalState(getter: () -> T, dependencies: {}?): T - local value: T, setValue = React.useState(getter) +local function useSignalState(getter: () -> T, dependencies: { any }?): T + local value, setValue = React.useState(getter) - React.useLayoutEffect(function(): () -> () - return Charm.listen(getter, setValue :: (T, T?) -> ()) - end, dependencies :: { any } or {}) + React.useLayoutEffect(function() + return Charm.effect(function() + -- Wrap in untracked to avoid tracking inner effect and atom calls + Charm.untracked(setValue, getter()) + end) + end, dependencies or {}) return value end @@ -34,19 +37,22 @@ end @param dependencies An array of values used to memoize the getter. @return A binding that stores the current state. ]] -local function useSignalBinding(getter: () -> T, dependencies: {}?) +local function useSignalBinding(getter: () -> T, dependencies: { any }?) local initialValue = React.useMemo(getter, {}) local binding, setBinding = React.useBinding(initialValue) - React.useLayoutEffect(function(): () -> () - return Charm.listen(getter, setBinding :: (T, T?) -> ()) - end, dependencies :: { any } or {}) + React.useLayoutEffect(function() + return Charm.effect(function() + -- Wrap in untracked to avoid tracking inner effect and atom calls + Charm.untracked(setBinding, getter()) + end) + end, dependencies or {}) return binding end -return table.freeze({ +return { useSignalState = useSignalState, useSignalBinding = useSignalBinding, useAtom = useSignalState, -}) +} diff --git a/packages/react-charm/test/init.luau b/packages/react-charm/test/init.luau deleted file mode 100644 index 25679e5..0000000 --- a/packages/react-charm/test/init.luau +++ /dev/null @@ -1,13 +0,0 @@ -local test = require("../../tests/test") - -_G.__ROACT_17_MOCK_SCHEDULER__ = true - -test.describe("useSignalState", function() - require("@self/useSignalState.test") -end) - -test.describe("useSignalBinding", function() - require("@self/useSignalBinding.test") -end) - -return {} diff --git a/packages/react-charm/wally.toml b/packages/react-charm/wally.toml index 51ee9ab..11d42ce 100644 --- a/packages/react-charm/wally.toml +++ b/packages/react-charm/wally.toml @@ -1,11 +1,11 @@ [package] name = "littensy/react-charm" -version = "0.4.1" +version = "0.4.0-rc.3" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ "node_modules", - "test", + "src/__tests__", "src/index.d.ts", "package.json", "React.luau", @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0" +Charm = "littensy/charm@^0.11.0-rc.3" React = "jsdotlua/react@^17" diff --git a/packages/vide-charm/Charm.luau b/packages/vide-charm/Charm.luau index 49baa5a..9588118 100644 --- a/packages/vide-charm/Charm.luau +++ b/packages/vide-charm/Charm.luau @@ -1 +1,5 @@ -return require(script.Parent.Parent.charm.src) +local Charm = require(script.Parent.Parent.charm.src) + +export type Atom = Charm.Atom + +return Charm diff --git a/packages/vide-charm/package.json b/packages/vide-charm/package.json index 9b64809..e9e2560 100644 --- a/packages/vide-charm/package.json +++ b/packages/vide-charm/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/vide-charm", - "version": "0.4.1", + "version": "0.4.0-rc.3", "description": "Vide bindings for Charm state management", "license": "MIT", "main": "src/init.luau", @@ -11,18 +11,18 @@ "!wally.toml", "!wally.lock", "!default.project.json", - "!test" + "!**/__tests__" ], "repository": { "type": "git", "url": "git+https://github.com/littensy/charm.git" }, "scripts": { - "prepare": "cp ../../LICENSE . && cp ../../README.md .", + "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "publish:wally": "wally publish" }, "dependencies": { - "@rbxts/charm": "workspace:^" + "@rbxts/charm": "workspace:*" }, "peerDependencies": { "@rbxts/vide": "*" diff --git a/packages/vide-charm/src/__tests__/spec.luau b/packages/vide-charm/src/__tests__/spec.luau new file mode 100644 index 0000000..79a4998 --- /dev/null +++ b/packages/vide-charm/src/__tests__/spec.luau @@ -0,0 +1,7 @@ +local suite = require("../../../../test/suite") + +suite.describe("useSignalState", function() + require("./useSignalState.spec") +end) + +return {} diff --git a/packages/vide-charm/test/useSignalState.test.luau b/packages/vide-charm/src/__tests__/useSignalState.spec.luau similarity index 88% rename from packages/vide-charm/test/useSignalState.test.luau rename to packages/vide-charm/src/__tests__/useSignalState.spec.luau index 9661501..6d13460 100644 --- a/packages/vide-charm/test/useSignalState.test.luau +++ b/packages/vide-charm/src/__tests__/useSignalState.spec.luau @@ -1,11 +1,13 @@ -local Charm = require("../Charm") -local Vide = require("../Vide") -local VideCharm = require("../src") -local test = require("../../../tests/test") +local Charm = require("../../Charm") +local Vide = require("../../Vide") +local VideCharm = require("../") +local suite = require("../../../../test/suite") + +local test = suite.test test("tracks signal state", function() local getState, setState = Charm.signal(0) - local source: () -> number + local source local cleanup = Vide.root(function() source = VideCharm.useSignalState(getState) end) @@ -17,7 +19,7 @@ end) test("accepts a selector", function() local getState, setState = Charm.signal(0) - local source: () -> number + local source local cleanup = Vide.root(function() source = VideCharm.useSignalState(function() return 2 * getState() diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index 5a27f54..073e759 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -11,7 +11,10 @@ local Vide = require("./Vide") local function useSignalState(getter: () -> State): () -> State local state = Vide.source(getter()) - Vide.cleanup(Charm.listen(getter, state :: (State, State?) -> ())) + Vide.cleanup(Charm.effect(function() + -- Wrap in untracked to avoid tracking inner effect and atom calls + Charm.untracked(state :: (State) -> State, getter()) + end)) return state end diff --git a/packages/vide-charm/test/init.luau b/packages/vide-charm/test/init.luau deleted file mode 100644 index 7b99c24..0000000 --- a/packages/vide-charm/test/init.luau +++ /dev/null @@ -1,7 +0,0 @@ -local test = require("../../tests/test") - -test.describe("useSignalState", function() - require("@self/useSignalState.test") -end) - -return {} diff --git a/packages/vide-charm/wally.toml b/packages/vide-charm/wally.toml index 4aa1f71..a48b236 100644 --- a/packages/vide-charm/wally.toml +++ b/packages/vide-charm/wally.toml @@ -1,11 +1,11 @@ [package] name = "littensy/vide-charm" -version = "0.4.1" +version = "0.4.0-rc.3" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = [ "node_modules", - "test", + "src/__tests__", "src/index.d.ts", "package.json", "Vide.luau", @@ -13,5 +13,5 @@ exclude = [ ] [dependencies] -Charm = "littensy/charm@^0.11.0" -Vide = "centau/vide@^0.4" +Charm = "littensy/charm@^0.11.0-rc.3" +Vide = "centau/vide@^0.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5c8ddc..312a389 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,186 +1,3 @@ ---- -lockfileVersion: '9.0' - -importers: - - .: - configDependencies: {} - packageManagerDependencies: - '@pnpm/exe': - specifier: 12.8.1 - version: 12.8.1 - pnpm: - specifier: 12.8.1 - version: 12.8.1 - -packages: - - '@pnpm/exe.android-arm64@12.8.1': - resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} - cpu: [arm64] - os: [android] - - '@pnpm/exe.android-x64@12.8.1': - resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} - cpu: [x64] - os: [android] - - '@pnpm/exe.darwin-arm64@12.8.1': - resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} - cpu: [arm64] - os: [darwin] - - '@pnpm/exe.darwin-x64@12.8.1': - resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} - cpu: [x64] - os: [darwin] - - '@pnpm/exe.freebsd-x64@12.8.1': - resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} - cpu: [x64] - os: [freebsd] - - '@pnpm/exe.linux-arm64-musl@12.8.1': - resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@pnpm/exe.linux-arm64@12.8.1': - resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-ppc64@12.8.1': - resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-riscv64@12.8.1': - resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-s390x@12.8.1': - resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-x64-musl@12.8.1': - resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@pnpm/exe.linux-x64@12.8.1': - resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.win32-arm64@12.8.1': - resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} - cpu: [arm64] - os: [win32] - - '@pnpm/exe.win32-x64@12.8.1': - resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} - cpu: [x64] - os: [win32] - - '@pnpm/exe@12.8.1': - resolution: {integrity: sha512-ju5MeefhEEXI7NO3u6oj54uGBOkN0pVhI+tcmuZDkTTqVdRZvEVEo+zhjwAoe8vEXguQCvsa6cTDhmFPxpOXeQ==} - engines: {node: '>=18.*'} - hasBin: true - - pnpm@12.8.1: - resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} - engines: {node: '>=18.*'} - hasBin: true - -snapshots: - - '@pnpm/exe.android-arm64@12.8.1': - optional: true - - '@pnpm/exe.android-x64@12.8.1': - optional: true - - '@pnpm/exe.darwin-arm64@12.8.1': - optional: true - - '@pnpm/exe.darwin-x64@12.8.1': - optional: true - - '@pnpm/exe.freebsd-x64@12.8.1': - optional: true - - '@pnpm/exe.linux-arm64-musl@12.8.1': - optional: true - - '@pnpm/exe.linux-arm64@12.8.1': - optional: true - - '@pnpm/exe.linux-ppc64@12.8.1': - optional: true - - '@pnpm/exe.linux-riscv64@12.8.1': - optional: true - - '@pnpm/exe.linux-s390x@12.8.1': - optional: true - - '@pnpm/exe.linux-x64-musl@12.8.1': - optional: true - - '@pnpm/exe.linux-x64@12.8.1': - optional: true - - '@pnpm/exe.win32-arm64@12.8.1': - optional: true - - '@pnpm/exe.win32-x64@12.8.1': - optional: true - - '@pnpm/exe@12.8.1': - optionalDependencies: - '@pnpm/exe.android-arm64': 12.8.1 - '@pnpm/exe.android-x64': 12.8.1 - '@pnpm/exe.darwin-arm64': 12.8.1 - '@pnpm/exe.darwin-x64': 12.8.1 - '@pnpm/exe.freebsd-x64': 12.8.1 - '@pnpm/exe.linux-arm64': 12.8.1 - '@pnpm/exe.linux-arm64-musl': 12.8.1 - '@pnpm/exe.linux-ppc64': 12.8.1 - '@pnpm/exe.linux-riscv64': 12.8.1 - '@pnpm/exe.linux-s390x': 12.8.1 - '@pnpm/exe.linux-x64': 12.8.1 - '@pnpm/exe.linux-x64-musl': 12.8.1 - '@pnpm/exe.win32-arm64': 12.8.1 - '@pnpm/exe.win32-x64': 12.8.1 - - pnpm@12.8.1: - optionalDependencies: - '@pnpm/exe.android-arm64': 12.8.1 - '@pnpm/exe.android-x64': 12.8.1 - '@pnpm/exe.darwin-arm64': 12.8.1 - '@pnpm/exe.darwin-x64': 12.8.1 - '@pnpm/exe.freebsd-x64': 12.8.1 - '@pnpm/exe.linux-arm64': 12.8.1 - '@pnpm/exe.linux-arm64-musl': 12.8.1 - '@pnpm/exe.linux-ppc64': 12.8.1 - '@pnpm/exe.linux-riscv64': 12.8.1 - '@pnpm/exe.linux-s390x': 12.8.1 - '@pnpm/exe.linux-x64': 12.8.1 - '@pnpm/exe.linux-x64-musl': 12.8.1 - '@pnpm/exe.win32-arm64': 12.8.1 - '@pnpm/exe.win32-x64': 12.8.1 - ---- lockfileVersion: '9.0' settings: @@ -192,124 +9,121 @@ importers: .: devDependencies: '@eslint/js': - specifier: ^9.39.4 - version: 9.39.4 + specifier: ^9.36.0 + version: 9.36.0 '@rbxts/compiler-types': specifier: 3.0.0-types.0 version: 3.0.0-types.0 '@rbxts/react': - specifier: 17.3.7-ts.1 - version: 17.3.7-ts.1 + specifier: 17.3.5-alpha.1 + version: 17.3.5-alpha.1 '@rbxts/react-roblox': - specifier: 17.3.7-ts.1 - version: 17.3.7-ts.1(@rbxts/react@17.3.7-ts.1) + specifier: 17.3.5-alpha.1 + version: 17.3.5-alpha.1(@rbxts/react@17.3.5-alpha.1) '@rbxts/types': - specifier: ^1.0.913 - version: 1.0.913 + specifier: ^1.0.883 + version: 1.0.883 '@rbxts/vide': - specifier: ^0.6.1 - version: 0.6.1 + specifier: ^0.5.7 + version: 0.5.7 '@typescript-eslint/eslint-plugin': - specifier: ^8.58.0 - version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + specifier: ^8.44.1 + version: 8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2))(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) '@typescript-eslint/parser': - specifier: ^8.58.0 - version: 8.58.0(eslint@9.39.4)(typescript@5.9.3) + specifier: ^8.44.1 + version: 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) eslint: - specifier: ^9.39.4 - version: 9.39.4 + specifier: ^9.36.0 + version: 9.36.0(jiti@2.6.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4) + version: 10.1.8(eslint@9.36.0(jiti@2.6.0)) eslint-plugin-prettier: - specifier: ^5.5.5 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.1) + specifier: ^5.5.4 + version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.36.0(jiti@2.6.0)))(eslint@9.36.0(jiti@2.6.0))(prettier@3.6.2) eslint-plugin-roblox-ts: - specifier: ^1.4.0 - version: 1.4.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + specifier: ^1.2.1 + version: 1.2.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2))(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + jiti: + specifier: ^2.6.0 + version: 2.6.0 prettier: - specifier: ^3.8.1 - version: 3.8.1 + specifier: ^3.6.2 + version: 3.6.2 roblox-ts: specifier: ^3.0.0 version: 3.0.0 typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: ^5.9.2 + version: 5.9.2 typescript-eslint: - specifier: ^8.58.0 - version: 8.58.0(eslint@9.39.4)(typescript@5.9.3) + specifier: ^8.44.1 + version: 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) packages/charm: {} packages/charm-sync: dependencies: '@rbxts/charm': - specifier: workspace:^ - version: link:../charm - - packages/deep-charm: - dependencies: - '@rbxts/charm': - specifier: workspace:^ + specifier: workspace:* version: link:../charm packages/react-charm: dependencies: '@rbxts/charm': - specifier: workspace:^ + specifier: workspace:* version: link:../charm '@rbxts/react': - specifier: '>=17.0.0' + specifier: '*' version: 17.2.3 packages/vide-charm: dependencies: '@rbxts/charm': - specifier: workspace:^ + specifier: workspace:* version: link:../charm '@rbxts/vide': specifier: '*' - version: 0.6.0 + version: 0.5.7 packages: - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.2': - resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + '@eslint/config-array@0.21.0': + resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + '@eslint/config-helpers@0.3.1': + resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + '@eslint/core@0.15.2': + resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + '@eslint/eslintrc@3.3.1': + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + '@eslint/js@9.36.0': + resolution: {integrity: sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + '@eslint/object-schema@2.1.6': + resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + '@eslint/plugin-kit@0.3.5': + resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@humanfs/core@0.19.1': @@ -328,6 +142,18 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -356,29 +182,29 @@ packages: '@rbxts-js/number@1.2.3-ts.1': resolution: {integrity: sha512-tPdt2s7fEBSxImVFBUvuSMtBKIvaZUCtlOjBl58Y0+GKB15VPXFIRW/5biVruXPnv9d4sDE3mh986ZxxN4UQ5w==} - '@rbxts-js/react-globals@17.3.7-ts.1': - resolution: {integrity: sha512-vwMHsc3Oy2MDAw2M212nQJ8A7nEG5O/lyXDyMGzYJp4832awhrf34YCkU4dxBE4sdMDcoWykhmuPjmzwrcS9Eg==} + '@rbxts-js/react-globals@17.3.5-ts.1': + resolution: {integrity: sha512-dbQ4E9WFtGg93cwElXk64KEYyd9gX2SCkTmYcm+Wty3qQdPkTWcaxu7c4SIMJpsvDTkt1nl+D0PdvVmQErcheg==} - '@rbxts-js/react-reconciler@17.3.7-ts.1': - resolution: {integrity: sha512-MxKU48i1gv4djcgHTq3geMvl6MzXPW85FO+itLg0YCvqT3WgE3jsMAc0nneOFi5Mwd5tqybQY6UFF1SbQtBopA==} + '@rbxts-js/react-reconciler@17.3.5-ts.1': + resolution: {integrity: sha512-Hy/Z4K5epqRFjchd7RAKHnSinAp6vviqqPFmwIoW8RJDwxkvcu7GeXmh6SkFv0FeRatYIic6vTry/0cu/AluYg==} - '@rbxts-js/react-roblox@17.3.7-ts.1': - resolution: {integrity: sha512-xiJQXmJAho/mStcWYC7AXFqgUqW78whUMacaNeODMimQXgoVqaIfo0sEhLtLtS/N3qZRs1KR64nsNG/0n1r/NQ==} + '@rbxts-js/react-roblox@17.3.5-ts.1': + resolution: {integrity: sha512-7AvkYe9lpIGcO+uHOnoqze46/8qMTpwKrqt+55gg6GLoWelVzseTkib5gGFRU4gi7CBpXvltiB8fY+T+IcPPOQ==} - '@rbxts-js/react@17.3.7-ts.1': - resolution: {integrity: sha512-nRHYoBzk9DSbOvk281jmN5EM3TlmKv2jOzLgSIGtmNMuwZKi9yEjjxdluS1wHmjSvus1YiPHhBUwrKnbu01Obg==} + '@rbxts-js/react@17.3.5-ts.1': + resolution: {integrity: sha512-Ln5r32y72s83T6qdpWX2hzd1sxoMf9GNx4PJnVVdlV+a3HJRC16huThTgMXyVD3mXlOnQwUKHs9ZLTFrSRNPUA==} '@rbxts-js/roblox-lua-promise@3.5.0-ts.2': resolution: {integrity: sha512-H8fxKVJkRnM60yTZ/riO4X43GZD/MViFm+ftCzzAALkAhE4vSaBDNv9E7IUjZZoWi21bG6vZ8mGIiYfavaljvw==} - '@rbxts-js/safe-flags@17.3.7-ts.1': - resolution: {integrity: sha512-aQ/5aQ/wQO5npifAbTEpR3jQRMeyGsVc91AQEyhBW461lPv6ZEg+Ad2HJFSzg4eeK2mb8Z9WyIMBrb01Jt+GnQ==} + '@rbxts-js/safe-flags@17.3.5-ts.1': + resolution: {integrity: sha512-lzuUf2TNdmgxLeZuw9gfbrzNjju7LrmGwI4gQQxgXR3M+QhjjRaaXiLrHFkC83+VdBHJht70Re5gBK/7VtNOHg==} - '@rbxts-js/scheduler@17.3.7-ts.1': - resolution: {integrity: sha512-WCgGQqH9/+A5oJd6HpxMj7Vp1VGqhycnJK0CZTsJl/Ceyf/VH/vif8fo1FrBWkxYEgz/7y0q3LOeRj8ArhyDWQ==} + '@rbxts-js/scheduler@17.3.5-ts.1': + resolution: {integrity: sha512-IT3dWnruzhEp7iShNOIsyVdy1jCCnHGV1B7Hhgusr7viwH8u5UrXZcOXazVuJM4ImdfPl1Yi9Bee+RKhW4zbuQ==} - '@rbxts-js/shared@17.3.7-ts.1': - resolution: {integrity: sha512-B/OCkRmbR6ho1yuf0M0hQIb78coDY//2XdmNBaX2XCN5pKa7dquiX6ufk4dizJ/OMBoy9br/cnxqcI0PLvla3w==} + '@rbxts-js/shared@17.3.5-ts.1': + resolution: {integrity: sha512-0iwX5dD+gpwoRPBH0fpRCL3MVtnMrQqKX4IOg4xqeLewMzGXwP00+uXvewSN+eIPtLisMYjqH9GdGNUJOO7oGw==} '@rbxts-js/string@1.2.3-ts.1': resolution: {integrity: sha512-Je/WKgZbXF/sAHEsQPuns5n4rWzux327zjr0KRf9Gy62ieUzl2FRZZHjVYce3xdMtHif2JstIf/J3p513TUtRg==} @@ -392,10 +218,10 @@ packages: '@rbxts/compiler-types@3.0.0-types.0': resolution: {integrity: sha512-VGOHJPoL7+56NTatMGqQj3K7xWuzEV+aP4QD5vZiHu+bcff3kiTmtoadaF6NkJrmwfFAvbsd4Dg764ZjWNceag==} - '@rbxts/react-roblox@17.3.7-ts.1': - resolution: {integrity: sha512-lsH3lP+X0JUS/6Fg5MJfP8OjMn7bjxapbg3r1ajrpvwZGPGU1WOFcFDS/xMMk/2RK/GNYRmkduNM8pQ9vHw54Q==} + '@rbxts/react-roblox@17.3.5-alpha.1': + resolution: {integrity: sha512-U7gb08OCDZTFp178gTVrgE5S1Ee6VBa3aHeIVcGhK1EJr8kn3dst2ugOtgeZIUQKPSBfxh885sKKcOfFScW5oA==} peerDependencies: - '@rbxts/react': ^17.3.7-ts.1 + '@rbxts/react': ^17.3.5-alpha.1 '@rbxts/react-vendor@17.2.3': resolution: {integrity: sha512-dWCiVelvTK6h920geaFH/OQ5zoWHyhyqyfH51hKx2/iVZ6OiiyNVJ5UqEIkJMG4HsJ8JnkTORizV3+MtQ41BtA==} @@ -403,24 +229,18 @@ packages: '@rbxts/react@17.2.3': resolution: {integrity: sha512-IoOB8e2W8MxT0MKWFf0Ls0vT61ImYL6ctHQgz5+oazma6z8bqFtJjgPxOuy6jmMSuyfBe7rYp/muou0kIjZT+Q==} - '@rbxts/react@17.3.7-ts.1': - resolution: {integrity: sha512-/jqZ2TvyLSAPwC30h95loUmy4XN2PwPhv6fBARJKtZfyozFx2RuxC9FiQSjpEZ2uOOPzr60It0MBUf94D+tsMg==} + '@rbxts/react@17.3.5-alpha.1': + resolution: {integrity: sha512-qYhZZtfH0neWmzfQvo05sQFx9EYNQCH8aZomqDdoX40bbnB9XlK1P5kl98xYXCX1uBZCAjj6cWPIUCCfZVE8hw==} - '@rbxts/types@1.0.913': - resolution: {integrity: sha512-AweuNYRVJb6y4PNajx2mnpqv+hm0+8GWtH9Dj4EMUuOVN5qcfcOMNSqjvQ4dmF5u0yQ89HKGeXa+4sLnaTIEng==} + '@rbxts/types@1.0.883': + resolution: {integrity: sha512-X/SgSPqES8awoqSehdEfvOCQjFPcTGA4EflfWZ/FXyWvlfd7Y5/DWa3UfUJFGDHZu1D0iDIL66q17UYQbufpMg==} - '@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==} + '@rbxts/vide@0.5.7': + resolution: {integrity: sha512-Q9R7ybzuHK1phtLicBHgAjQnlJ/dMsXDs2k296PIMLgLqK9aX/f1skOP7F1K/vo7IrSE0jCG3B3XTOtQL1Sb0Q==} '@roblox-ts/luau-ast@2.0.0': resolution: {integrity: sha512-cmMi093IdwBOLVxwuordhM8AmtbyTIyRpsTbB0D/JauidW4SXsQRQowSwWjHo4QP0DRJBXvOIlxtqEQi50uNzQ==} - '@roblox-ts/luau-ast@2.0.1': - resolution: {integrity: sha512-jej2mzRGwhR9wu4woAtgTwqsKADlegZYlQ+M5qVHRojvjoyJVFkLrSbhBKRr8HR+fVW5nCz0qX7kNa0tSpAY+A==} - '@roblox-ts/path-translator@1.1.0': resolution: {integrity: sha512-D0akTmnNYqBw+ZIek5JxocT3BjmbgGOuOy0x1nIIxHBPNLGCpzseToY8jyYs/0mlvnN2xnSP/k8Tv+jvGOQSwQ==} @@ -433,107 +253,107 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@typescript-eslint/eslint-plugin@8.58.0': - resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} + '@typescript-eslint/eslint-plugin@8.44.1': + resolution: {integrity: sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.58.0 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser': ^8.44.1 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.58.0': - resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} + '@typescript-eslint/parser@8.44.1': + resolution: {integrity: sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==} 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' + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.56.0': - resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} + '@typescript-eslint/project-service@8.44.0': + resolution: {integrity: sha512-ZeaGNraRsq10GuEohKTo4295Z/SuGcSq2LzfGlqiuEvfArzo/VRrT0ZaJsVPuKZ55lVbNk8U6FcL+ZMH8CoyVA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@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/scope-manager@8.58.0': - resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.56.0': - resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} + '@typescript-eslint/project-service@8.44.1': + resolution: {integrity: sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@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: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/type-utils@8.56.0': - resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} - 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/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==} + '@typescript-eslint/scope-manager@8.44.0': + resolution: {integrity: sha512-87Jv3E+al8wpD+rIdVJm/ItDBe/Im09zXIjFoipOjr5gHUhJmTzfFLuTJ/nPTMc2Srsroy4IBXwcTCHyRR7KzA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.58.0': - resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} + '@typescript-eslint/scope-manager@8.44.1': + resolution: {integrity: sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.56.0': - resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} + '@typescript-eslint/tsconfig-utils@8.44.0': + resolution: {integrity: sha512-x5Y0+AuEPqAInc6yd0n5DAcvtoQ/vyaGwuX5HE9n6qAefk1GaedqrLQF8kQGylLUb9pnZyLf+iEiL9fr8APDtQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/typescript-estree@8.58.0': - resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} + '@typescript-eslint/tsconfig-utils@8.44.1': + resolution: {integrity: sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/utils@8.56.0': - resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} - 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/utils@8.58.0': - resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} + '@typescript-eslint/type-utils@8.44.0': + resolution: {integrity: sha512-9cwsoSxJ8Sak67Be/hD2RNt/fsqmWnNE1iHohG8lxqLSNY8xNfyY7wloo5zpW3Nu9hxVgURevqfcH6vvKCt6yg==} 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' + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.56.0': - resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} + '@typescript-eslint/type-utils@8.44.1': + resolution: {integrity: sha512-KdEerZqHWXsRNKjF9NYswNISnFzXfXNDfPxoTh7tqohU/PRIbwTmsjGK6V9/RTYWau7NZvfo52lgVk+sJh0K3g==} + 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-eslint/types@8.44.0': + resolution: {integrity: sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==} 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==} + '@typescript-eslint/types@8.44.1': + resolution: {integrity: sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.44.0': + resolution: {integrity: sha512-lqNj6SgnGcQZwL4/SBJ3xdPEfcBuhCG8zdcwCPgYcmiPLgokiNDKlbPzCwEwu7m279J/lBYWtDYL+87OEfn8Jw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/typescript-estree@8.44.1': + resolution: {integrity: sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.44.0': + resolution: {integrity: sha512-nktOlVcg3ALo0mYlV+L7sWUD58KG4CMj1rb2HUVOO4aL3K/6wcD+NERqd0rrA5Vg06b42YhF6cFxeixsp9Riqg==} + 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-eslint/utils@8.44.1': + resolution: {integrity: sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg==} + 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-eslint/visitor-keys@8.44.0': + resolution: {integrity: sha512-zaz9u8EJ4GBmnehlrpoKvj/E3dNbuQ7q0ucyZImm3cLqJ8INTc970B1qEqDX/Rzq65r3TvVTN7kHWPBoyW7DWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.44.1': + resolution: {integrity: sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} acorn-jsx@5.3.2: @@ -541,16 +361,16 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} hasBin: true - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -570,23 +390,15 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - brace-expansion@2.0.3: - resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} - - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} - engines: {node: 18 || 20 || >=22} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -651,8 +463,8 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-plugin-prettier@5.5.5: - resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} + eslint-plugin-prettier@5.5.4: + resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: '@types/eslint': '>=8.0.0' @@ -665,8 +477,8 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-roblox-ts@1.4.0: - resolution: {integrity: sha512-OWj+JfQlvGSiAJ5YOmiQc+M29ZJFc7VFYP0U1J3orZNT9GP5CWfpRNqPHuDhsvz+ni0BwoI4aqGv2bIPDueGrQ==} + eslint-plugin-roblox-ts@1.2.1: + resolution: {integrity: sha512-B6PyXsz2urPJIr2TxZBD3qQwCM0FN6lUOoddJTk1AcdRkWSTe1Vc6VuNP593u7Tbq9sStjW2k2iYjI1G5CFNiQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=22.5.1} peerDependencies: '@typescript-eslint/parser': '>=6.0.0' @@ -685,12 +497,8 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + eslint@9.36.0: + resolution: {integrity: sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -703,8 +511,8 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} esrecurse@4.3.0: @@ -725,6 +533,10 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -734,14 +546,8 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} @@ -759,11 +565,11 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - fs-extra@11.3.4: - resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} + fs-extra@11.3.2: + resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} engines: {node: '>=14.14'} fsevents@2.3.3: @@ -793,6 +599,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -844,8 +653,12 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + jiti@2.6.0: + resolution: {integrity: sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true json-buffer@3.0.1: @@ -881,15 +694,19 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} - minimatch@9.0.9: - resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} ms@2.1.3: @@ -929,24 +746,20 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier-linter-helpers@1.0.1: - resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} + prettier-linter-helpers@1.0.0: + resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} engines: {node: '>=6.0.0'} - prettier@3.8.1: - resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} engines: {node: '>=14'} hasBin: true @@ -954,6 +767,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -970,17 +786,24 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} 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 - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} hasBin: true @@ -1012,20 +835,16 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - synckit@0.11.12: - resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + synckit@0.11.11: + resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + ts-api-utils@2.1.0: + resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -1034,20 +853,20 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.58.0: - resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} + typescript-eslint@8.44.1: + resolution: {integrity: sha512-0ws8uWGrUVTjEeN2OM4K1pLKHK/4NiNP/vz6ns+LjT/6sqpaYzIVFajZb1fj/IDwpsrrHb3Jy0Qm5u9CPcKaeg==} 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' + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' typescript@5.5.3: resolution: {integrity: sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==} engines: {node: '>=14.17'} hasBin: true - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} hasBin: true @@ -1089,50 +908,48 @@ packages: snapshots: - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + '@eslint-community/eslint-utils@4.9.0(eslint@9.36.0(jiti@2.6.0))': dependencies: - eslint: 9.39.4 + eslint: 9.36.0(jiti@2.6.0) eslint-visitor-keys: 3.4.3 - '@eslint-community/regexpp@4.12.2': {} + '@eslint-community/regexpp@4.12.1': {} - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.21.0': dependencies: - '@eslint/object-schema': 2.1.7 + '@eslint/object-schema': 2.1.6 debug: 4.4.3 - minimatch: 3.1.5 + minimatch: 3.1.2 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.4.2': - dependencies: - '@eslint/core': 0.17.0 + '@eslint/config-helpers@0.3.1': {} - '@eslint/core@0.17.0': + '@eslint/core@0.15.2': dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5': + '@eslint/eslintrc@3.3.1': dependencies: - ajv: 6.14.0 + ajv: 6.12.6 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.5 + js-yaml: 4.1.0 + minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.39.4': {} + '@eslint/js@9.36.0': {} - '@eslint/object-schema@2.1.7': {} + '@eslint/object-schema@2.1.6': {} - '@eslint/plugin-kit@0.4.1': + '@eslint/plugin-kit@0.3.5': dependencies: - '@eslint/core': 0.17.0 + '@eslint/core': 0.15.2 levn: 0.4.1 '@humanfs/core@0.19.1': {} @@ -1146,6 +963,18 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + '@pkgr/core@0.2.9': {} '@rbxts-js/boolean@1.2.3-ts.1': @@ -1182,51 +1011,51 @@ snapshots: '@rbxts-js/number@1.2.3-ts.1': {} - '@rbxts-js/react-globals@17.3.7-ts.1': + '@rbxts-js/react-globals@17.3.5-ts.1': dependencies: - '@rbxts-js/safe-flags': 17.3.7-ts.1 + '@rbxts-js/safe-flags': 17.3.5-ts.1 - '@rbxts-js/react-reconciler@17.3.7-ts.1': + '@rbxts-js/react-reconciler@17.3.5-ts.1': dependencies: '@rbxts-js/luau-polyfill': 1.2.3-ts.1 - '@rbxts-js/react': 17.3.7-ts.1 - '@rbxts-js/react-globals': 17.3.7-ts.1 + '@rbxts-js/react': 17.3.5-ts.1 + '@rbxts-js/react-globals': 17.3.5-ts.1 '@rbxts-js/roblox-lua-promise': 3.5.0-ts.2 - '@rbxts-js/safe-flags': 17.3.7-ts.1 - '@rbxts-js/scheduler': 17.3.7-ts.1 - '@rbxts-js/shared': 17.3.7-ts.1 + '@rbxts-js/safe-flags': 17.3.5-ts.1 + '@rbxts-js/scheduler': 17.3.5-ts.1 + '@rbxts-js/shared': 17.3.5-ts.1 - '@rbxts-js/react-roblox@17.3.7-ts.1': + '@rbxts-js/react-roblox@17.3.5-ts.1': dependencies: '@rbxts-js/luau-polyfill': 1.2.3-ts.1 - '@rbxts-js/react': 17.3.7-ts.1 - '@rbxts-js/react-globals': 17.3.7-ts.1 - '@rbxts-js/react-reconciler': 17.3.7-ts.1 - '@rbxts-js/scheduler': 17.3.7-ts.1 - '@rbxts-js/shared': 17.3.7-ts.1 + '@rbxts-js/react': 17.3.5-ts.1 + '@rbxts-js/react-globals': 17.3.5-ts.1 + '@rbxts-js/react-reconciler': 17.3.5-ts.1 + '@rbxts-js/scheduler': 17.3.5-ts.1 + '@rbxts-js/shared': 17.3.5-ts.1 - '@rbxts-js/react@17.3.7-ts.1': + '@rbxts-js/react@17.3.5-ts.1': dependencies: '@rbxts-js/luau-polyfill': 1.2.3-ts.1 - '@rbxts-js/react-globals': 17.3.7-ts.1 - '@rbxts-js/shared': 17.3.7-ts.1 + '@rbxts-js/react-globals': 17.3.5-ts.1 + '@rbxts-js/shared': 17.3.5-ts.1 '@rbxts-js/roblox-lua-promise@3.5.0-ts.2': {} - '@rbxts-js/safe-flags@17.3.7-ts.1': {} + '@rbxts-js/safe-flags@17.3.5-ts.1': {} - '@rbxts-js/scheduler@17.3.7-ts.1': + '@rbxts-js/scheduler@17.3.5-ts.1': dependencies: '@rbxts-js/luau-polyfill': 1.2.3-ts.1 - '@rbxts-js/react-globals': 17.3.7-ts.1 - '@rbxts-js/safe-flags': 17.3.7-ts.1 - '@rbxts-js/shared': 17.3.7-ts.1 + '@rbxts-js/react-globals': 17.3.5-ts.1 + '@rbxts-js/safe-flags': 17.3.5-ts.1 + '@rbxts-js/shared': 17.3.5-ts.1 - '@rbxts-js/shared@17.3.7-ts.1': + '@rbxts-js/shared@17.3.5-ts.1': dependencies: '@rbxts-js/luau-polyfill': 1.2.3-ts.1 - '@rbxts-js/react-globals': 17.3.7-ts.1 - '@rbxts-js/safe-flags': 17.3.7-ts.1 + '@rbxts-js/react-globals': 17.3.5-ts.1 + '@rbxts-js/safe-flags': 17.3.5-ts.1 '@rbxts-js/string@1.2.3-ts.1': dependencies: @@ -1241,10 +1070,10 @@ snapshots: '@rbxts/compiler-types@3.0.0-types.0': {} - '@rbxts/react-roblox@17.3.7-ts.1(@rbxts/react@17.3.7-ts.1)': + '@rbxts/react-roblox@17.3.5-alpha.1(@rbxts/react@17.3.5-alpha.1)': dependencies: - '@rbxts-js/react-roblox': 17.3.7-ts.1 - '@rbxts/react': 17.3.7-ts.1 + '@rbxts-js/react-roblox': 17.3.5-ts.1 + '@rbxts/react': 17.3.5-alpha.1 '@rbxts/react-vendor@17.2.3': {} @@ -1252,202 +1081,201 @@ snapshots: dependencies: '@rbxts/react-vendor': 17.2.3 - '@rbxts/react@17.3.7-ts.1': + '@rbxts/react@17.3.5-alpha.1': dependencies: - '@rbxts-js/react': 17.3.7-ts.1 + '@rbxts-js/react': 17.3.5-ts.1 - '@rbxts/types@1.0.913': {} + '@rbxts/types@1.0.883': {} - '@rbxts/vide@0.6.0': {} - - '@rbxts/vide@0.6.1': {} + '@rbxts/vide@0.5.7': {} '@roblox-ts/luau-ast@2.0.0': {} - '@roblox-ts/luau-ast@2.0.1': {} - '@roblox-ts/path-translator@1.1.0': dependencies: - ajv: 8.18.0 - fs-extra: 11.3.4 + ajv: 8.17.1 + fs-extra: 11.3.2 '@roblox-ts/rojo-resolver@1.1.0': dependencies: - ajv: 8.18.0 - fs-extra: 11.3.4 + ajv: 8.17.1 + fs-extra: 11.3.2 '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} - '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2))(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.0 - eslint: 9.39.4 + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + '@typescript-eslint/scope-manager': 8.44.1 + '@typescript-eslint/type-utils': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + '@typescript-eslint/utils': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 8.44.1 + eslint: 9.36.0(jiti@2.6.0) + graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': dependencies: - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/scope-manager': 8.44.1 + '@typescript-eslint/types': 8.44.1 + '@typescript-eslint/typescript-estree': 8.44.1(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 8.44.1 debug: 4.4.3 - eslint: 9.39.4 - typescript: 5.9.3 + eslint: 9.36.0(jiti@2.6.0) + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.44.0(typescript@5.9.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) - '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.9.2) + '@typescript-eslint/types': 8.44.0 debug: 4.4.3 - typescript: 5.9.3 + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.44.1(typescript@5.9.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) - '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/tsconfig-utils': 8.44.1(typescript@5.9.2) + '@typescript-eslint/types': 8.44.1 debug: 4.4.3 - typescript: 5.9.3 + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.56.0': + '@typescript-eslint/scope-manager@8.44.0': dependencies: - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/visitor-keys': 8.56.0 + '@typescript-eslint/types': 8.44.0 + '@typescript-eslint/visitor-keys': 8.44.0 - '@typescript-eslint/scope-manager@8.58.0': + '@typescript-eslint/scope-manager@8.44.1': dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/types': 8.44.1 + '@typescript-eslint/visitor-keys': 8.44.1 - '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.44.0(typescript@5.9.2)': dependencies: - typescript: 5.9.3 + typescript: 5.9.2 - '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.44.1(typescript@5.9.2)': dependencies: - typescript: 5.9.3 + typescript: 5.9.2 - '@typescript-eslint/type-utils@8.56.0(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.44.0(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': dependencies: - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/types': 8.44.0 + '@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.2) + '@typescript-eslint/utils': 8.44.0(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) debug: 4.4.3 - eslint: 9.39.4 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + eslint: 9.36.0(jiti@2.6.0) + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': 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) + '@typescript-eslint/types': 8.44.1 + '@typescript-eslint/typescript-estree': 8.44.1(typescript@5.9.2) + '@typescript-eslint/utils': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) debug: 4.4.3 - eslint: 9.39.4 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + eslint: 9.36.0(jiti@2.6.0) + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.56.0': {} + '@typescript-eslint/types@8.44.0': {} - '@typescript-eslint/types@8.58.0': {} + '@typescript-eslint/types@8.44.1': {} - '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.44.0(typescript@5.9.2)': dependencies: - '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/visitor-keys': 8.56.0 + '@typescript-eslint/project-service': 8.44.0(typescript@5.9.2) + '@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.9.2) + '@typescript-eslint/types': 8.44.0 + '@typescript-eslint/visitor-keys': 8.44.0 debug: 4.4.3 - minimatch: 9.0.9 - semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.2 + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.44.1(typescript@5.9.2)': dependencies: - '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/project-service': 8.44.1(typescript@5.9.2) + '@typescript-eslint/tsconfig-utils': 8.44.1(typescript@5.9.2) + '@typescript-eslint/types': 8.44.1 + '@typescript-eslint/visitor-keys': 8.44.1 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 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.2 + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.0(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/utils@8.44.0(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': dependencies: - '@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.4 - typescript: 5.9.3 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0(jiti@2.6.0)) + '@typescript-eslint/scope-manager': 8.44.0 + '@typescript-eslint/types': 8.44.0 + '@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.2) + eslint: 9.36.0(jiti@2.6.0) + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/utils@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - eslint: 9.39.4 - typescript: 5.9.3 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0(jiti@2.6.0)) + '@typescript-eslint/scope-manager': 8.44.1 + '@typescript-eslint/types': 8.44.1 + '@typescript-eslint/typescript-estree': 8.44.1(typescript@5.9.2) + eslint: 9.36.0(jiti@2.6.0) + typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.56.0': + '@typescript-eslint/visitor-keys@8.44.0': dependencies: - '@typescript-eslint/types': 8.56.0 - eslint-visitor-keys: 5.0.1 + '@typescript-eslint/types': 8.44.0 + eslint-visitor-keys: 4.2.1 - '@typescript-eslint/visitor-keys@8.58.0': + '@typescript-eslint/visitor-keys@8.44.1': dependencies: - '@typescript-eslint/types': 8.58.0 - eslint-visitor-keys: 5.0.1 + '@typescript-eslint/types': 8.44.1 + eslint-visitor-keys: 4.2.1 - acorn-jsx@5.3.2(acorn@8.16.0): + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: - acorn: 8.16.0 + acorn: 8.15.0 - acorn@8.16.0: {} + acorn@8.15.0: {} - ajv@6.14.0: + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.18.0: + ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.0 @@ -1463,29 +1291,23 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.2 + picomatch: 2.3.1 argparse@2.0.1: {} balanced-match@1.0.2: {} - balanced-match@4.0.4: {} - binary-extensions@2.3.0: {} - brace-expansion@1.1.13: + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.3: + brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.5: - dependencies: - balanced-match: 4.0.4 - braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -1541,27 +1363,26 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.4): + eslint-config-prettier@10.1.8(eslint@9.36.0(jiti@2.6.0)): dependencies: - eslint: 9.39.4 + eslint: 9.36.0(jiti@2.6.0) - eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.1): + eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.36.0(jiti@2.6.0)))(eslint@9.36.0(jiti@2.6.0))(prettier@3.6.2): dependencies: - eslint: 9.39.4 - prettier: 3.8.1 - prettier-linter-helpers: 1.0.1 - synckit: 0.11.12 + eslint: 9.36.0(jiti@2.6.0) + prettier: 3.6.2 + prettier-linter-helpers: 1.0.0 + synckit: 0.11.11 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@9.39.4) + eslint-config-prettier: 10.1.8(eslint@9.36.0(jiti@2.6.0)) - eslint-plugin-roblox-ts@1.4.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3): + eslint-plugin-roblox-ts@1.2.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2))(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2): dependencies: - '@roblox-ts/luau-ast': 2.0.1 - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 - typescript: 5.9.3 + '@typescript-eslint/parser': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + '@typescript-eslint/type-utils': 8.44.0(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + '@typescript-eslint/utils': 8.44.0(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + eslint: 9.36.0(jiti@2.6.0) + typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -1574,23 +1395,22 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint-visitor-keys@5.0.1: {} - - eslint@9.39.4: + eslint@9.36.0(jiti@2.6.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 - '@eslint/plugin-kit': 0.4.1 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0(jiti@2.6.0)) + '@eslint-community/regexpp': 4.12.1 + '@eslint/config-array': 0.21.0 + '@eslint/config-helpers': 0.3.1 + '@eslint/core': 0.15.2 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.36.0 + '@eslint/plugin-kit': 0.3.5 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.14.0 + '@types/json-schema': 7.0.15 + ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -1598,7 +1418,7 @@ snapshots: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - esquery: 1.7.0 + esquery: 1.6.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 8.0.0 @@ -1609,19 +1429,21 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.5 + minimatch: 3.1.2 natural-compare: 1.4.0 optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.0 transitivePeerDependencies: - supports-color espree@10.4.0: dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 - esquery@1.7.0: + esquery@1.6.0: dependencies: estraverse: 5.3.0 @@ -1637,15 +1459,23 @@ snapshots: fast-diff@1.3.0: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} fast-uri@3.1.0: {} - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 + fastq@1.19.1: + dependencies: + reusify: 1.1.0 file-entry-cache@8.0.0: dependencies: @@ -1662,12 +1492,12 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.2 + flatted: 3.3.3 keyv: 4.5.4 - flatted@3.4.2: {} + flatted@3.3.3: {} - fs-extra@11.3.4: + fs-extra@11.3.2: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.0 @@ -1692,6 +1522,8 @@ snapshots: graceful-fs@4.2.11: {} + graphemer@1.4.0: {} + has-flag@4.0.0: {} hasown@2.0.2: @@ -1729,7 +1561,9 @@ snapshots: isexe@2.0.0: {} - js-yaml@4.1.1: + jiti@2.6.0: {} + + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -1764,17 +1598,20 @@ snapshots: lodash.merge@4.6.2: {} - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.5 + merge2@1.4.1: {} - minimatch@3.1.5: + micromatch@4.0.8: dependencies: - brace-expansion: 1.1.13 + braces: 3.0.3 + picomatch: 2.3.1 - minimatch@9.0.9: + minimatch@3.1.2: dependencies: - brace-expansion: 2.0.3 + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 ms@2.1.3: {} @@ -1809,23 +1646,23 @@ snapshots: path-parse@1.0.7: {} - picomatch@2.3.2: {} - - picomatch@4.0.4: {} + picomatch@2.3.1: {} prelude-ls@1.2.1: {} - prettier-linter-helpers@1.0.1: + prettier-linter-helpers@1.0.0: dependencies: fast-diff: 1.3.0 - prettier@3.8.1: {} + prettier@3.6.2: {} punycode@2.3.1: {} + queue-microtask@1.2.3: {} + readdirp@3.6.0: dependencies: - picomatch: 2.3.2 + picomatch: 2.3.1 require-directory@2.1.1: {} @@ -1833,25 +1670,31 @@ snapshots: resolve-from@4.0.0: {} - resolve@1.22.11: + resolve@1.22.10: dependencies: is-core-module: 2.16.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + reusify@1.1.0: {} + roblox-ts@3.0.0: dependencies: '@roblox-ts/luau-ast': 2.0.0 '@roblox-ts/path-translator': 1.1.0 '@roblox-ts/rojo-resolver': 1.1.0 chokidar: 3.6.0 - fs-extra: 11.3.4 + fs-extra: 11.3.2 kleur: 4.1.5 - resolve: 1.22.11 + resolve: 1.22.10 typescript: 5.5.3 yargs: 17.7.2 - semver@7.7.4: {} + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + semver@7.7.2: {} shebang-command@2.0.0: dependencies: @@ -1877,41 +1720,36 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - synckit@0.11.12: + synckit@0.11.11: dependencies: '@pkgr/core': 0.2.9 - tinyglobby@0.2.15: - dependencies: - 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.5.0(typescript@5.9.3): + ts-api-utils@2.1.0(typescript@5.9.2): dependencies: - typescript: 5.9.3 + typescript: 5.9.2 type-check@0.4.0: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.58.0(eslint@9.39.4)(typescript@5.9.3): + typescript-eslint@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2): dependencies: - '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 - typescript: 5.9.3 + '@typescript-eslint/eslint-plugin': 8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2))(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + '@typescript-eslint/parser': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + '@typescript-eslint/typescript-estree': 8.44.1(typescript@5.9.2) + '@typescript-eslint/utils': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2) + eslint: 9.36.0(jiti@2.6.0) + typescript: 5.9.2 transitivePeerDependencies: - supports-color typescript@5.5.3: {} - typescript@5.9.3: {} + typescript@5.9.2: {} universalify@2.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d8424bf..924b55f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,2 @@ packages: - packages/* -publicHoistPattern: - - "*@rbxts*" diff --git a/rokit.toml b/rokit.toml index ca169e2..17f523d 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,6 +1,13 @@ +# This file lists tools managed by Rokit, a toolchain manager for Roblox projects. +# For more information, see https://github.com/rojo-rbx/rokit + +# New tools can be added by running `rokit add ` in a terminal. + [tools] wally = "UpliftGames/wally@0.3.2" -rojo = "rojo-rbx/rojo@7.7.0" -stylua = "johnnymorganz/stylua@2.5.2" -luau-lsp = "johnnymorganz/luau-lsp@1.70.1" -lute = "luau-lang/lute@1.0.0" +rojo = "rojo-rbx/rojo@7.6.1" +stylua = "johnnymorganz/stylua@2.3.1" +selene = "Kampfkarren/selene@0.29.0" +luau-lsp = "johnnymorganz/luau-lsp@1.56.2" +lune = "lune-org/lune@0.10.4" +lute = "luau-lang/lute@0.1.0-nightly.20251119" diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..1b04ccf --- /dev/null +++ b/selene.toml @@ -0,0 +1,6 @@ +std = "selene_defs" +exclude = ["*/node_modules/*"] + +[lints] +global_usage = "allow" +shadowing = "allow" diff --git a/selene_defs.yml b/selene_defs.yml new file mode 100644 index 0000000..89d1377 --- /dev/null +++ b/selene_defs.yml @@ -0,0 +1,6 @@ +base: roblox +name: selene_defs +globals: + require: + args: + - type: string diff --git a/test/spec.server.luau b/test/spec.server.luau new file mode 100644 index 0000000..dfb6d0b --- /dev/null +++ b/test/spec.server.luau @@ -0,0 +1,23 @@ +local Charm = require("../packages/charm/src") +local suite = require("./suite") + +Charm.flags.strict = true +Charm.flags.frozen = true + +suite.describe("charm", function() + require("../packages/charm/src/__tests__/spec") +end) + +suite.describe("charm-sync", function() + require("../packages/charm-sync/src/__tests__/spec") +end) + +suite.describe.ingame("react-charm", function() + require("../packages/react-charm/src/__tests__/spec") +end) + +suite.describe.ingame("vide-charm", function() + require("../packages/vide-charm/src/__tests__/spec") +end) + +suite.finish() diff --git a/test/suite b/test/suite new file mode 160000 index 0000000..eb95781 --- /dev/null +++ b/test/suite @@ -0,0 +1 @@ +Subproject commit eb95781a86e505cb6bff83ca568a67c7295ebb38 diff --git a/tests/run.server.luau b/tests/run.server.luau deleted file mode 100644 index 44015cb..0000000 --- a/tests/run.server.luau +++ /dev/null @@ -1,33 +0,0 @@ -local Charm = require("../packages/charm/src") -local test = require("./test") - -Charm.flags.strict = true -Charm.flags.frozen = true - -local describeInRoblox = test.describeIf(function() - return not not game -end) - -test.describe("charm", function() - require("../packages/charm/test") -end) - -test.describe("charm-sync", function() - require("../packages/charm-sync/test") -end) - -test.describe("deep-charm", function() - require("../packages/deep-charm/test") -end) - -describeInRoblox("react-charm", function() - require("../packages/react-charm/test") -end) - -describeInRoblox("vide-charm", function() - require("../packages/vide-charm/test") -end) - -test.finish() - -return {} diff --git a/tests/test b/tests/test deleted file mode 160000 index 7ef2b88..0000000 --- a/tests/test +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7ef2b88191af884d543e7392d50c7e2adc484e32 diff --git a/tsconfig.json b/tsconfig.json index bf99b80..c0db2ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,10 +4,11 @@ "allowSyntheticDefaultImports": true, "downlevelIteration": true, "jsx": "react", - "jsxFactory": "React.createElement", - "jsxFragmentFactory": "React.Fragment", + "jsxFactory": "Roact.createElement", + "jsxFragmentFactory": "Roact.createFragment", "module": "commonjs", "moduleResolution": "Node", + "ignoreDeprecations": "6.0", "noLib": true, "resolveJsonModule": true, "experimentalDecorators": true,