feat!: use alien-signals for reactivity (#50)

* feat: use alien-signals

* refactor: remove pnpm, fix tests

* fix: `effectScope` should set scope

* fix: check previous value before calling listener

* refactor: improve imports

* refactor: exclude re-exports from wally

* refactor: embrace scope cleanup, restructure code

* fix: update pkgs dir references

* ci: simplify pesde install

* build: install dev deps with wally

* feat: add detached, add more tests

* test: simplify mapped test update

* chore: rephrase doc comment

* perf: optimize subscribe and mapped

* refactor: avoid ambiguous optional function type

* refactor: add assertion error messages

* perf: optimize mapped, add benches

* chore: delete redundant benchmarks

* perf: simplify mapped logic

* test: prettify test library

* test: try different gray

* test: color brackets

* refactor: sync 2.0.4

* refactor: use setCurrentSub instead of pauseTracking

* test: improve test suite

* test: separate parent from root

* refactor: iterate over previous data in mapped

* refactor: improve logic

* style: use `---` doc comments

* refactor: improve logic

* chore: housekeeping

* ci: add pnpm step

* ci: init submodules

* test: use lune

* refactor: improve structure and alien-signals integration

* ci: update test file path

* feat: add peek and roblox-ts types

* feat: add deep freeze flag, misc refactors

* refactor: reduce type casting, housekeeping

* refactor: rewrite untracked, update alien-signals

* chore: update eslint, housekeeping

* refactor: start charm-sync rewrite

* fix: send init payload even if atom did not update

* test: add all sync tests

* feat: prepare for alpha release

* feat: add peek, make untracked escape scope

* fix: wrap hooks in untracked, update types

* feat: add flag for inner effect tracking

* refactor: add docs, remove some modules

* refactor: align with alien-signals 3.0

* feat: add signal api

* refactor: update charm sync api

* feat: update alien-signals

* refactor: remove unnecessary features, update docs

* feat: finalize sync docs

* docs: update sync section

* docs: simplify examples

* docs: capitalize headers

* docs: add migration tips

* docs: fix whitespace

* docs: rephrase computed arg change

* refactor: rename batch, misc fixes & perf updates

* refactor: simplify charm sync validation

* feat: update alien-signals, add recursion flag

* feat: add `recursive()`

* fix: allow dispose in observers

* feat: add `trigger()`, refactor source

* docs: update readme

* chore: prepare release candidate

* test: make some tests more reliable

* docs: fix typo

* feat: add `reactive()`

* chore: bump versions

* docs: reword reactive docs

* docs: fix typo

* feat: add `toRaw()`

* fix: check for nil diffs

* chore: bump versions

* perf: do not create proxies for iteration

* fix: make `toRaw` track all calls

* fix: check none before patching mutable tables

* chore: bump charm-sync version

* docs: add fishing game example project

* feat: sync signals that return reactive proxy

* fix: refine reactive proxy logic

* fix: prevent early reactive gc, alien-signals 3.1.1

* fix: wrap iterated values in `reactive()`

* fix: loosen client signal types

* chore: bump release candidate

* fix: remove unsynced signals immediately

* test: use lute for tests

* chore: bump versions

* ci: set up lute

* refactor: improve source readability

* ci: upgrade lute

* build: loosen workspace version requirements

* chore: bump deps

* fix: update signal overload for setter (#58)

* fix: add missing recursed check

* test: add important vue reactivity tests

* chore: fix react tests, run stylua

* chore: update vide

* docs: improve intro and explain getters

* docs: adjust ordering by importance

* docs: move listen nesting note

* build: fix react peer dep for ci

* chore: update lockfile

* ci: use javascript eslint config

* ci: ignore eslint config file

* refactor: move test directories

* feat: allow recursion in effects by default

* release: charm v0.11.0-rc.4

* refactor: remove recursive()

* refactor: reorganize repo, create deep-charm

* docs: rephrase some sections

* feat: export getter and setter types

* docs: rephrase intro again

* feat!: run listen and subscribe callback in untracked

* refactor!: remove peek

* docs: note peek in migration guide

* release: charm v0.11.0-rc.5

* release: charm-sync v0.4.0-rc.7

* release: react-charm v0.4.0-rc.4

* fix: remove old keys when updating key in `mapped()`

* refactor: shorten error traceback, housekeeping

* refactor: keep error level in some cases

* fix: check if cleanup functions disposed effect

* refactor!: run effects in batched

* ci: check eslint and prettier

* perf: run observe fn in untracked

* perf: call mapper as-is in `mapped()`

* refactor: error for effect cleanup errors

* refactor: use new type solver

* ci: disable new solver flag until 0.714

* chore: housekeeping

* refactor: improve errors in nonstrict, optimize noyield

* chore: housekeeping

* refactor: satisfy new solver

* refactor: improve error traceback

* docs: link roblox signals

* chore: housekeeping

* perf: remove `clearPending` inline operation

* refactor: check type of cleanup

* ci: remove selene

* fix: always return pending value in signal setter

* test: add multi benchmark

* release: charm v0.11.0-rc.6

* test: add cleanup order tests

* chore: bump deps

* docs: add warning for deep-charm and note for `observe()`

---------

Co-authored-by: safazi <safazics@gmail.com>
This commit is contained in:
richard 2026-06-20 20:51:17 -07:00 • committed by GitHub
parent d0b7cff943
commit 573533058f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
148 changed files with 9222 additions and 4384 deletions

View file

@ -0,0 +1 @@
return require(script.Parent.Parent.charm.src)

View file

@ -0,0 +1 @@
return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").React)

View file

@ -0,0 +1 @@
return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").ReactRoblox)

View file

@ -0,0 +1,6 @@
{
"name": "react-charm",
"tree": {
"$path": "src"
}
}

View file

@ -0,0 +1,30 @@
{
"name": "@rbxts/react-charm",
"version": "0.4.0-rc.4",
"description": "React bindings for Charm state management",
"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:^"
},
"peerDependencies": {
"@rbxts/react": ">=17.0.0"
}
}

31
packages/react-charm/src/index.d.ts vendored Normal file
View file

@ -0,0 +1,31 @@
import { Binding } from "@rbxts/react";
/**
* Returns a React state that updates when the getter function updates.
* Re-renders the component when the value changes.
*
* If the `dependencies` array is provided, a new effect is created for the
* current `getter` when the dependencies change. Otherwise, the effect is
* created only once, when the component is mounted.
*
* @param getter The signal or selector to watch.
* @param dependencies An array of values used to memoize the getter.
* @return The current state.
*/
export function useSignalState<T>(getter: () => T, dependencies?: any[]): T;
/**
* Returns a React binding that updates when the getter function updates.
*
* If the `dependencies` array is provided, a new effect is created for the
* current `getter` when the dependencies change. Otherwise, the effect is
* created only once, when the component is mounted.
*
* @param getter The atom or selector to watch.
* @param dependencies An array of values used to memoize the getter.
* @return A binding that stores the current state.
*/
export function useSignalBinding<T>(getter: () => T, dependencies?: any[]): Binding<T>;
// Aliases
export { useSignalState as useAtom };

View file

@ -0,0 +1,52 @@
local Charm = require("./Charm")
local React = require("./React")
--[[
Returns a React state that updates when the getter function updates.
Re-renders the component when the value changes.
If the `dependencies` array is provided, a new effect is created for the
current `getter` when the dependencies change. Otherwise, the effect is
created only once, when the component is mounted.
@param getter The signal or selector to watch.
@param dependencies An array of values used to memoize the getter.
@return The current state.
]]
local function useSignalState<T>(getter: () -> T, dependencies: {}?): T
local value, setValue = React.useState(getter)
React.useLayoutEffect(function(): () -> ()
return Charm.listen(getter, setValue :: (T, T?) -> ())
end, dependencies :: { any } or {})
return value
end
--[[
Returns a React binding that updates when the getter function updates.
If the `dependencies` array is provided, a new effect is created for the
current `getter` when the dependencies change. Otherwise, the effect is
created only once, when the component is mounted.
@param getter The atom or selector to watch.
@param dependencies An array of values used to memoize the getter.
@return A binding that stores the current state.
]]
local function useSignalBinding<T>(getter: () -> T, dependencies: {}?)
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 {})
return binding
end
return table.freeze({
useSignalState = useSignalState,
useSignalBinding = useSignalBinding,
useAtom = useSignalState,
})

View file

@ -0,0 +1,39 @@
local React = require("../../React")
local ReactRoblox = require("../../ReactRoblox")
type RenderedHook<Props, Result> = {
result: Result,
rerender: (Props?) -> (),
unmount: () -> (),
}
local function renderHook<Props, Result>(callback: (Props) -> Result, props: Props?): RenderedHook<Props, Result>
local root = ReactRoblox.createLegacyRoot(Instance.new("Folder"))
local hook = {}
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)
end)
end
function hook.unmount()
ReactRoblox.act(function()
root:unmount()
end)
end
ReactRoblox.act(function()
hook.rerender(props)
end)
return hook
end
return renderHook

View file

@ -0,0 +1,13 @@
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 {}

View file

@ -0,0 +1,44 @@
local Charm = require("../Charm")
local ReactCharm = require("../src")
local renderHook = require("./helpers/renderHook")
local test = require("../../../tests/test")
test("tracks signal state", function()
local getState, setState = Charm.signal(0)
local hook = renderHook(function()
return ReactCharm.useSignalBinding(getState)
end)
assert(hook.result:getValue() == 0, "should return 0")
setState(1)
assert(hook.result:getValue() == 1, "should return 1")
end)
test("accepts a selector", function()
local getState, setState = Charm.signal(0)
local hook = renderHook(function()
return ReactCharm.useSignalBinding(function()
return 2 * getState()
end)
end)
assert(hook.result:getValue() == 0, "should return 0")
setState(1)
assert(hook.result:getValue() == 2, "should return 2")
end)
test("tracks dependencies", function()
local getState, setState = Charm.signal(0)
local hook = renderHook(function()
return ReactCharm.useSignalBinding(getState, { getState })
end)
assert(hook.result:getValue() == 0, "should return 0")
setState(1)
assert(hook.result:getValue() == 1, "should return 1")
getState, setState = Charm.signal(0)
hook.rerender()
assert(hook.result:getValue() == 0, "should return 0")
setState(1)
assert(hook.result:getValue() == 1, "should return 1")
end)
return {}

View file

@ -0,0 +1,44 @@
local Charm = require("../Charm")
local ReactCharm = require("../src")
local renderHook = require("./helpers/renderHook")
local test = require("../../../tests/test")
test("tracks signal state", function()
local getState, setState = Charm.signal(0)
local hook = renderHook(function()
return ReactCharm.useSignalState(getState)
end)
assert(hook.result == 0, "should return 0")
setState(1)
assert(hook.result == 1, "should return 1")
end)
test("accepts a selector", function()
local getState, setState = Charm.signal(0)
local hook = renderHook(function()
return ReactCharm.useSignalState(function()
return 2 * getState()
end)
end)
assert(hook.result == 0, "should return 0")
setState(1)
assert(hook.result == 2, "should return 2")
end)
test("tracks dependencies", function()
local getState, setState = Charm.signal(0)
local hook = renderHook(function()
return ReactCharm.useSignalState(getState, { getState })
end)
assert(hook.result == 0, "should return 0")
setState(1)
assert(hook.result == 1, "should return 1")
getState, setState = Charm.signal(0)
hook.rerender()
assert(hook.result == 0, "should return 0")
setState(1)
assert(hook.result == 1, "should return 1")
end)
return {}

View file

@ -0,0 +1,17 @@
[package]
name = "littensy/react-charm"
version = "0.4.0-rc.4"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = [
"node_modules",
"test",
"src/index.d.ts",
"package.json",
"React.luau",
"Charm.luau",
]
[dependencies]
Charm = "littensy/charm@^0.11.0-rc.6"
React = "jsdotlua/react@^17"