feat: initial commit

This commit is contained in:
Richard 2024-02-25 03:41:09 -08:00
commit 18723c1377
50 changed files with 3238 additions and 0 deletions

26
.eslintrc Normal file
View file

@ -0,0 +1,26 @@
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"jsx": true,
"useJSXTextNode": true,
"ecmaVersion": 2018,
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": [
"@typescript-eslint",
"roblox-ts",
"prettier"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:roblox-ts/recommended",
"plugin:prettier/recommended"
],
"rules": {
"prettier/prettier": "warn",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-declaration-merging": "off"
}
}

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
* text=auto eol=lf
*.luau linguist-language=Lua

36
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,36 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 8.15.4
- name: Install Aftman
uses: ok-nick/setup-aftman@v0.4.2
- name: Install dependencies
run: |
pnpm install
pnpm wally
- name: Check code quality
run: pnpm lint
- name: Run ESLint
run: pnpm eslint
- name: Build model
run: rojo build -o charm.rbxm

29
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,29 @@
name: Release
on:
release:
types:
- published
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Aftman
uses: ok-nick/setup-aftman@v0.4.2
- name: Install dependencies
run: wally install
- name: Build model
run: rojo build -o charm.rbxm
- name: Upload release
uses: softprops/action-gh-release@v2
if: ${{ github.event.release }}
with:
files: charm.rbxm
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
/node_modules
/Packages
/DevPackages
/sourcemap.json
*.tsbuildinfo
*.tgz
.DS_Store

3
.luaurc Normal file
View file

@ -0,0 +1,3 @@
{
"languageMode": "strict"
}

6
.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": true
}

5
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"luau-lsp.sourcemap.rojoProjectFile": "dev.project.json",
"luau-lsp.types.definitionFiles": ["bin/testez.d.luau"],
"luau-lsp.completion.imports.requireStyle": "alwaysRelative"
}

7
LICENSE.md Normal file
View file

@ -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.

142
README.md Normal file
View file

@ -0,0 +1,142 @@
<p align="center">
<p align="center">
<img width="150" height="150" src="images/logo.png" alt="Logo">
</p>
<h1 align="center"><b>Charm</b></h1>
<p align="center">
Atomic state management for Roblox.
<br />
<a href="https://npmjs.com/package/@rbxts/charm"><strong>npm package →</strong></a>
</p>
</p>
<div align="center">
![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/littensy/charm/ci.yml?style=for-the-badge&branch=master&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.md)
</div>
**Charm** is a Roblox state management library, inspired by [Jotai](https://jotai.org). Designed to be an alternative to [Reflex](https://littensy.github.io/reflex), Charm aims to bring an atomic and composable approach to your game's state.
> [!NOTE]
> Charm is incomplete and not ready for use.
## 📦 Setup
### TypeScript
Install Charm through your package manager of choice. \
[React](https://github.com/littensy/rbxts-react) is optional, but highly recommended.
```sh
npm install @rbxts/charm
yarn add @rbxts/charm
pnpm add @rbxts/charm
```
### Wally
Add `littensy/charm` to your `wally.toml` file.
```toml
[dependencies]
Charm = "littensy/charm@VERSION"
```
## 📚 Documentation
### `atom(state)`
### `derive(...atoms, combiner)`
### `subscribe(atom, listener)`
### `observe(atom, observer)`
### `split(atom, keyExtractor?)`
---
### `Atom(state?)`
### `Atom.get(selector?)`
### `Atom.set(state)`
### `Atom.memo(equalityFn)`
### `Atom.readonly()`
### `Atom.writable(onSet?)`
### `Atom.named(label)`
### `Atom.unmount()`
---
### `useAtom(atom, selector?)`
### `useAtomState(atom)`
### `useSetAtom(atom)`
## 🚀 Examples
### Counter atom
```ts
import { atom, derive, subscribe } from "@rbxts/charm";
const counterAtom = atom(0);
const doubleCounterAtom = derive(counterAtom, (counter) => counter * 2);
subscribe(doubleCounterAtom, (value) => {
print(value);
});
counterAtom(1);
counterAtom((count) => count + 1);
```
### Counter component
```tsx
import React from "@rbxts/react";
import { useAtom } from "@rbxts/charm";
import { counterAtom } from "./counter-atom";
function Counter() {
const [count, setCount] = useAtom(counterAtom);
return (
<textlabel
Text={`Count: ${count}`}
Size={new UDim2(0, 100, 0, 50)}
Event={{
Activated: () => setCount(count + 1),
}}
/>
);
}
```
## 🙋 Concerns
In Jotai, `atom()` returns an [atom config](https://jotai.org/docs/core/atom), and its state is managed entirely by the Provider. However, in Charm, `atom()` returns something closer to a store, and its state is managed by the atom itself.
Charm does this for simplicity in use cases outside of React, but this might go against Jotai's focus on pure functional programming. More insight is needed to determine if this is a good idea.
---
<p align="center">
Charm is released under the <a href="LICENSE.md">MIT License</a>.
</p>
<div align="center">
[![MIT License](https://img.shields.io/badge/license-mit-f4dbd6?style=for-the-badge&labelColor=302d41)](LICENSE.md)
</div>

11
aftman.toml Normal file
View file

@ -0,0 +1,11 @@
# This file lists tools managed by Aftman, a cross-platform toolchain manager.
# For more information, see https://github.com/LPGhatguy/aftman
# To add a new tool, add an entry to this table.
[tools]
wally = "UpliftGames/wally@0.3.2"
rojo = "rojo-rbx/rojo@7.4.1"
wally-package-types = "JohnnyMorganz/wally-package-types@1.3.1"
stylua = "johnnymorganz/stylua@0.20.0"
selene = "Kampfkarren/selene@0.26.1"
luau-lsp = "johnnymorganz/luau-lsp@1.27.1"

18
bin/check.sh Normal file
View file

@ -0,0 +1,18 @@
curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.lua
rojo sourcemap dev.project.json -o sourcemap.json
luau-lsp analyze \
--defs=bin/roblox.d.luau \
--defs=bin/testez.d.luau \
--flag:LuauFixIndexerSubtypingOrdering=true \
--flag:LuauInstantiateInSubtyping=true \
--flag:LuauTinyControlFlowAnalysis=true \
--sourcemap=sourcemap.json \
--ignore="**/_Index/**" src
selene src
stylua --check src
eslint src
rm bin/roblox.d.luau

4
bin/install.sh Normal file
View file

@ -0,0 +1,4 @@
wally install
rojo sourcemap dev.project.json --output sourcemap.json
wally-package-types --sourcemap sourcemap.json Packages
wally-package-types --sourcemap sourcemap.json DevPackages

10
bin/spec.server.luau Normal file
View file

@ -0,0 +1,10 @@
--!nocheck
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packages = ReplicatedStorage.Packages
local DevPackages = ReplicatedStorage.DevPackages
local TestEZ = require(DevPackages.TestEZ)
TestEZ.TestBootstrap:run({ Packages.charm.__tests__ }, TestEZ.Reporters.TextReporter)

24
bin/testez.d.luau Normal file
View file

@ -0,0 +1,24 @@
declare function afterAll(callback: () -> ()): ()
declare function afterEach(callback: () -> ()): ()
declare function beforeAll(callback: () -> ()): ()
declare function beforeEach(callback: () -> ()): ()
declare function describe(phrase: string, callback: () -> ()): ()
declare function describeFOCUS(phrase: string, callback: () -> ()): ()
declare function fdescribe(phrase: string, callback: () -> ()): ()
declare function describeSKIP(phrase: string, callback: () -> ()): ()
declare function xdescribe(phrase: string, callback: () -> ()): ()
declare function expect(value: any): any
declare function FIXME(optionalMessage: string?): ()
declare function FOCUS(): ()
declare function SKIP(): ()
declare function it(phrase: string, callback: () -> ()): ()
declare function itFOCUS(phrase: string, callback: () -> ()): ()
declare function fit(phrase: string, callback: () -> ()): ()
declare function itSKIP(phrase: string, callback: () -> ()): ()
declare function xit(phrase: string, callback: () -> ()): ()
declare function itFIXME(phrase: string, callback: () -> ()): ()

6
default.project.json Normal file
View file

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

20
dev.project.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "charm-dev",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"Packages": {
"$path": "Packages",
"ReactLua": { "$path": "Packages" },
"charm": { "$path": "src" }
},
"DevPackages": { "$path": "DevPackages" }
},
"TestService": {
"$className": "TestService",
"$properties": { "ExecuteWithStudioRun": true },
"spec": { "$path": "bin/spec.server.luau" }
}
}
}

BIN
images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

58
package.json Normal file
View file

@ -0,0 +1,58 @@
{
"name": "@rbxts/charm",
"version": "0.1.0",
"description": "Atomic state management for Roblox",
"scripts": {
"lint": "sh bin/check.sh",
"wally": "sh bin/install.sh"
},
"main": "src/init.lua",
"types": "src/index.d.ts",
"keywords": [
"roblox",
"typescript",
"roblox-ts",
"state"
],
"author": "littensy",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/littensy/charm"
},
"bugs": {
"url": "https://github.com/littensy/charm/issues"
},
"homepage": "https://github.com/littensy/charm",
"files": [
"src",
"default.project.json",
"images/logo.png",
"!src/**/*.spec.ts"
],
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@rbxts/compiler-types": "^2.3.0-types.0",
"@rbxts/react": "^0.3.1",
"@rbxts/types": "^1.0.754",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-roblox-ts": "^0.0.36",
"prettier": "^3.2.5",
"roblox-ts": "2.3.0-dev-ac7800f",
"typescript": "^5.3.3"
},
"peerDependencies": {
"@rbxts/react": "*"
},
"peerDependenciesMeta": {
"@rbxts/react": {
"optional": true
}
}
}

1482
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

5
selene.toml Normal file
View file

@ -0,0 +1,5 @@
std = "roblox+testez"
[lints]
global_usage = "allow"
shadowing = "allow"

View file

@ -0,0 +1,85 @@
local atom = require(script.Parent.Parent.atom)
return function()
it("updates state", function()
local source = atom(1)
source(2)
expect(source()).to.equal(2)
source(function(x)
return x + 1
end)
expect(source()).to.equal(3)
end)
it("calls listeners", function()
local source = atom(1)
local value
local function listener(state)
value = state
end
source.listeners[listener] = true
source(2)
expect(value).to.equal(2)
end)
it("skips pending listeners", function()
local source = atom(1)
local outer = 0
local inner = 0
local function listener()
outer += 1
local function listener()
inner += 1
end
source.listeners[listener] = true
end
source.listeners[listener] = true
source(2)
expect(outer).to.equal(1)
expect(inner).to.equal(0)
source(3)
expect(outer).to.equal(2)
expect(inner).to.equal(1)
end)
it("can be readonly", function()
local readonly = atom(1):readonly()
expect(function()
(readonly :: any)(2)
end).to.throw()
local writable = readonly:writable()
writable(2)
expect(writable()).to.equal(2)
end)
it("calls the setter", function()
local source = atom(1):writable(function(_, value: number)
return value + 1
end)
source(1)
expect(source()).to.equal(2)
end)
it("cancels if setter returns nil", function()
local source = atom(1):writable(function(value: number)
return if value == 2 then nil else value
end)
source(2)
expect(source()).to.equal(1)
source(3)
expect(source()).to.equal(3)
end)
it("can be memoized", function()
local source = atom(1):memo(function(prev, next)
return next < prev
end)
source(2)
expect(source()).to.equal(2)
source(1)
expect(source()).to.equal(2)
end)
end

View file

@ -0,0 +1,92 @@
local atom = require(script.Parent.Parent.atom)
local derive = require(script.Parent.Parent.derive)
return function()
it("derives from dependencies", function()
local a = atom(1)
local b = atom(2)
local sum = derive(a, b, function(a: number, b: number)
return a + b
end)
expect(sum()).to.equal(3)
a(2)
expect(sum()).to.equal(4)
b(3)
expect(sum()).to.equal(5)
expect(function()
(sum :: any)(2)
end).to.throw()
end)
it("memoizes the result", function()
local calls = 0
local source = atom(1)
local double = derive(source, function(value: number)
calls += 1
return value * 2
end)
expect(calls).to.equal(1)
source(1)
expect(calls).to.equal(1)
source(2)
expect(calls).to.equal(2)
double()
expect(calls).to.equal(2)
end)
it("can be a dependency", function()
local source = atom(1)
local double = derive(source, function(value: number)
return value * 2
end)
local quadruple = derive(double, function(value: number)
return value * 2
end)
expect(quadruple()).to.equal(4)
source(2)
expect(quadruple()).to.equal(8)
end)
it("allows custom setter", function()
local source = atom(1)
local double = derive(source, function(value: number)
return value * 2
end):writable(function(update: number)
source(update / 2)
end)
double(4)
expect(source()).to.equal(2)
expect(double()).to.equal(4)
end)
it("garbage collects", function()
-- large values coerce the garbage collector to run
local source = atom(string.rep("x", 1e5))
local max = 0
local function listenersDecreased()
local count = 0
for _ in source.listeners do
count += 1
end
max = math.max(max, count)
return count < max
end
for index = 1, 500 do
derive(source, function(value)
return string.upper(value)
end)
if index % 50 == 0 then
-- derived atoms check if they are collected when they are updated
source(source() .. "x")
task.wait()
end
if listenersDecreased() then
return
end
end
error("None of the derived atoms were collected")
end)
end

View file

@ -0,0 +1,7 @@
local reference = require(script.Parent.Parent.utils.reference)
return function()
afterEach(function()
reference.clear()
end)
end

View file

@ -0,0 +1,33 @@
local atom = require(script.Parent.Parent.atom)
local observe = require(script.Parent.Parent.observe)
return function()
it("observes lifetime", function()
local itemsAtom = atom({ "a", "b", "c" })
local state = {}
local cleanup, flush = observe(itemsAtom, function(item, key)
state[key] = item
return function()
state[key] = nil
end
end)
for key = 1, 3 do
expect(state[key]).to.equal(itemsAtom:get()[key])
end
itemsAtom:set({ [4] = "d", [5] = "e", [6] = "f" })
flush()
for key = 1, 6 do
expect(state[key]).to.equal(itemsAtom:get()[key])
end
cleanup()
for key = 1, 6 do
expect(state[key]).to.equal(nil)
end
end)
end

View file

@ -0,0 +1,42 @@
_G.__ROACT_17_MOCK_SCHEDULER__ = true
local Package = script:FindFirstAncestor("__tests__").Parent
local React = require(Package.modules.React)
local ReactRoblox = require(Package.modules.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)
hook.result = callback(props)
end
function hook.rerender(newProps)
props = newProps or props
ReactRoblox.act(function()
root:render(React.createElement(Component, props))
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,27 @@
local atom = require(script.Parent.Parent.Parent.atom)
local useAtomState = require(script.Parent.Parent.Parent.react.useAtomState)
local renderHook = require(script.Parent.helpers.renderHook)
return function()
it("tracks atom state", function()
local source = atom(0)
local hook = renderHook(function()
return useAtomState(source)
end)
expect(hook.result).to.equal(0)
source(1)
expect(hook.result).to.equal(1)
end)
it("receives a selector", function()
local source = atom(0)
local hook = renderHook(function()
return useAtomState(source, function(value)
return 2 * value
end)
end)
expect(hook.result).to.equal(0)
source(1)
expect(hook.result).to.equal(2)
end)
end

View file

@ -0,0 +1,34 @@
local atom = require(script.Parent.Parent.Parent.atom)
local useSetAtom = require(script.Parent.Parent.Parent.react.useSetAtom)
local renderHook = require(script.Parent.helpers.renderHook)
return function()
it("sets atom state", function()
local source = atom(0)
local hook = renderHook(function()
return useSetAtom(source)
end)
expect(hook.result).to.be.a("function")
hook.result(1)
expect(source()).to.equal(1)
end)
it("returns a stable function", function()
local source = atom(0)
local hook = renderHook(function()
return useSetAtom(source)
end)
local fn = hook.result
hook.rerender()
expect(hook.result).to.equal(fn)
end)
it("memoizes the function", function()
local hook = renderHook(function(source)
return useSetAtom(source)
end, atom(0))
local fn = hook.result
hook.rerender(atom(1))
expect(hook.result).to.never.equal(fn)
end)
end

View file

@ -0,0 +1,128 @@
local atom = require(script.Parent.Parent.atom)
local isAtom = require(script.Parent.Parent.isAtom)
local observe = require(script.Parent.Parent.observe)
local split = require(script.Parent.Parent.split)
return function()
it("returns split atoms", function()
local itemsAtom = atom({ "a", "b", "c" })
local itemAtoms = split(itemsAtom)
local updates = 0
itemAtoms.listeners[function()
updates += 1
end] = true
for key = 1, 3 do
local itemAtom = itemAtoms()[key]
assert(isAtom(itemAtom), `itemAtom {key} is not an atom; got {typeof(itemAtom)}`)
expect(itemAtom()).to.equal(itemsAtom()[key])
end
local prevAtoms = itemAtoms()
itemsAtom({ "d", "e", "f" })
expect(updates).to.equal(1)
for key = 1, 3 do
local itemAtom = itemAtoms()[key]
assert(itemAtom == prevAtoms[key] :: any, `itemAtom {key} is not the same atom; got {typeof(itemAtom)}`)
expect(itemAtom()).to.equal(itemsAtom()[key])
end
itemsAtom({ [1] = "d", [3] = "f" })
expect(itemAtoms()[2]).to.never.be.ok()
expect(updates).to.equal(2)
end)
it("has writable items", function()
local itemsAtom = atom({ "a", "b", "c" })
local itemAtoms = split(itemsAtom)
local updates = 0
itemAtoms.listeners[function()
updates += 1
end] = true
itemAtoms()[2]("x")
expect(updates).to.equal(1)
expect(itemsAtom()[2]).to.equal("x")
expect(itemAtoms()[2]()).to.equal("x")
end)
it("uses key extractor", function()
local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } })
local itemAtoms = split(itemsAtom, function(item)
return item.key
end)
for key = 1, 3 do
local itemAtom = itemAtoms()[key]
expect(itemAtom()).to.equal(itemsAtom()[key])
end
local prevAtoms = itemAtoms()
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
expect(itemAtoms()[1]).to.equal(prevAtoms[1])
expect(itemAtoms()[1]()).to.equal(itemsAtom()[1])
expect(itemAtoms()[2]).to.never.be.ok()
expect(itemAtoms()[3]).to.equal(prevAtoms[3])
expect(itemAtoms()[3]()).to.equal(itemsAtom()[2])
end)
it("allows observe()", function()
local itemsAtom = atom({
{ key = 1, value = "a" },
{ key = 2, value = "b" },
{ key = 3, value = "c" },
})
local itemAtoms = split(itemsAtom, function(item)
return item.key
end)
local added = 0
local updated = 0
local deleted = 0
local _, flush = observe(itemAtoms, function(item, key)
expect(item()).to.equal(itemsAtom()[key])
item.listeners[function(value)
expect(value).to.equal(itemsAtom()[key])
updated += 1
end] = true
added += 1
return function()
expect(itemAtoms()[key]).to.never.be.ok()
deleted += 1
end
end)
expect(added).to.equal(3)
expect(updated).to.equal(0)
expect(deleted).to.equal(0)
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
flush()
expect(added).to.equal(3)
expect(updated).to.equal(0)
expect(deleted).to.equal(1)
itemAtoms()[1]({ key = 1, value = "x" })
flush()
expect(added).to.equal(3)
expect(updated).to.equal(1)
expect(deleted).to.equal(1)
itemsAtom({})
flush()
expect(added).to.equal(3)
expect(updated).to.equal(1)
expect(deleted).to.equal(3)
end)
end

View file

@ -0,0 +1,92 @@
local atom = require(script.Parent.Parent.atom)
local derive = require(script.Parent.Parent.derive)
local subscribe = require(script.Parent.Parent.subscribe)
return function()
it("runs on state change", function()
local current, previous
local source = atom(0)
local unsubscribe, flush = subscribe(source, function(...)
current, previous = ...
end)
for index = 1, 3 do
source(index)
flush()
expect(current).to.equal(index)
expect(previous).to.equal(index - 1)
end
unsubscribe()
source(4)
flush()
expect(current).to.equal(3)
expect(previous).to.equal(2)
end)
it("batches updates", function()
local current, previous
local source = atom(0)
local _, flush = subscribe(source, function(...)
current, previous = ...
end)
source(1)
source(2)
flush()
expect(current).to.equal(2)
expect(previous).to.equal(0)
end)
it("checks equality", function()
local current, previous
local source = atom(0):memo(function(previous, current)
return current < previous
end)
local _, flush = subscribe(source, function(...)
current, previous = ...
end)
source(1)
flush()
expect(current).to.equal(1)
expect(previous).to.equal(0)
source(0)
flush()
expect(current).to.equal(1)
expect(previous).to.equal(0)
end)
it("prevents garbage collect", function()
local source = atom(1)
local updates = 0
for _ = 1, 1000 do
-- The idea here is to test whether subscribe() prevents every derived
-- atom in a chain from being garbage collected. If any are GCed, then
-- the subscription will stop updating and the test will fail.
subscribe(
derive(
derive(source, function(x)
return x
end),
function(x)
return 2 * x
end
),
function()
updates += 1
end
)
end
source(source() + 1)
task.wait()
expect(updates).to.equal(1000)
end)
end

137
src/atom.luau Normal file
View file

@ -0,0 +1,137 @@
local reference = require(script.Parent.utils.reference)
type AtomInternal<T> = {
--- @deprecated
setForced: (self: AtomInternal<T>, state: T | ((T) -> T)) -> (),
--- @deprecated
listeners: { [(T) -> ()]: true },
--- @deprecated
debugTraceback: string,
--- @deprecated
debugLabel: string,
--- @deprecated
owners: { [any]: true },
}
export type Atom<T> = (() -> T) & ((state: T | ((T) -> T)) -> ()) & {
get: ((self: Atom<T>) -> T) & (<Result>(self: Atom<T>, selector: (T) -> Result) -> Result),
set: (self: Atom<T>, state: T | ((T) -> T)) -> (),
memo: (self: Atom<T>, equalityFn: (previous: T, current: T) -> boolean) -> Atom<T>,
writable: (self: Atom<T>, setter: ((update: T, previous: T) -> T?) & (update: T, previous: T) -> ()) -> Atom<T>,
readonly: (self: Atom<T>) -> ReadonlyAtom<T>,
named: (self: Atom<T>, label: string) -> Atom<T>,
unmount: (self: Atom<T>) -> (),
} & AtomInternal<T>
export type ReadonlyAtom<T> = (() -> T) & {
get: ((self: ReadonlyAtom<T>) -> T) & (<Result>(self: ReadonlyAtom<T>, selector: (T) -> Result) -> Result),
memo: (self: ReadonlyAtom<T>, equalityFn: (previous: T, current: T) -> boolean) -> ReadonlyAtom<T>,
writable: ((self: Atom<T>) -> Atom<T>) & (
(self: Atom<T>, setter: ((update: T, previous: T) -> T?) & (update: T, previous: T) -> ()) -> Atom<T>
),
readonly: (self: ReadonlyAtom<T>) -> ReadonlyAtom<T>,
named: (self: ReadonlyAtom<T>, label: string) -> ReadonlyAtom<T>,
unmount: (self: ReadonlyAtom<T>) -> (),
} & AtomInternal<T>
local atomCount = 0
local function atom<State>(state: State): Atom<State>
atomCount += 1
local listeners: { [(State) -> ()]: true } = {}
local label = `atom{atomCount}`
local traceback = debug.traceback(nil, 2)
local owners: { [any]: true } = {}
local atom = {
listeners = listeners,
debugTraceback = traceback,
debugLabel = label,
owners = owners,
} :: Atom<State>
local equals: ((State, State) -> boolean)?
local setter: ((State, State) -> State?)?
function atom:get<Result>(selector: ((State) -> Result)?): any
return if selector then selector(state) else state
end
function atom:set(update: State | ((State) -> State))
local nextState = if type(update) == "function" then update(state) else update
if setter then
local result = setter(nextState, state)
if result == nil then
return
else
nextState = result
end
end
if not (if equals then equals(state, nextState) else state == nextState) then
state = nextState
for listener in table.clone(listeners) do
listener(state)
end
end
end
function atom:setForced(update: State | ((State) -> State))
local nextState = if type(update) == "function" then update(state) else update
if not (if equals then equals(state, nextState) else state == nextState) then
state = nextState
for listener in table.clone(listeners) do
listener(state)
end
end
end
function atom:memo(equalityFn: (State, State) -> boolean): Atom<State>
equals = equalityFn
return self
end
function atom:writable(setterFn: ((State, State) -> State?)?): Atom<State>
setter = setterFn
return self
end
function atom:readonly(): ReadonlyAtom<State>
function setter(_, _)
error(`Attempt to set read-only atom '{label}' created at: {traceback}`, 3)
end
return self
end
function atom:named(name: string): Atom<State>
label = name
return self
end
function atom:unmount()
table.clear(listeners)
table.clear(owners)
reference.release(self)
end
setmetatable(atom :: AtomInternal<State>, {
__type = "Atom",
__call = function(self, ...)
if select("#", ...) > 0 then
return self:set((...))
else
return self:get()
end
end,
})
return atom
end
return atom

53
src/derive.luau Normal file
View file

@ -0,0 +1,53 @@
local atom = require(script.Parent.atom)
type Atom<T> = atom.Atom<T>
type ReadonlyAtom<T> = atom.ReadonlyAtom<T>
local reference = require(script.Parent.utils.reference)
type Derive =
(<A, T>(Atom<A>, (A) -> T) -> ReadonlyAtom<T>)
& (<A, B, T>(Atom<A>, Atom<B>, (A, B) -> T) -> ReadonlyAtom<T>)
& (<A, B, C, T>(Atom<A>, Atom<B>, Atom<C>, (A, B, C) -> T) -> ReadonlyAtom<T>)
& (<A, B, C, D, T>(Atom<A>, Atom<B>, Atom<C>, Atom<D>, (A, B, C, D) -> T) -> ReadonlyAtom<T>)
& (<A, B, C, D, E, T>(Atom<A>, Atom<B>, Atom<C>, Atom<D>, Atom<E>, (A, B, C, D, E) -> T) -> ReadonlyAtom<T>)
& (<T>(...(Atom<any> | ((...any) -> T))) -> ReadonlyAtom<T>)
local function derive<Result>(...): ReadonlyAtom<Result>
local atoms: { Atom<unknown> } = { ... }
local combiner: (...unknown) -> Result = table.remove(atoms) :: any
local size = #atoms
local dependencies: { unknown } = {}
for index, atom in atoms do
dependencies[index] = atom:get()
end
local derivedAtom = atom(combiner(table.unpack(dependencies, 1, size))):readonly()
local getDerivedAtom = reference.weak(derivedAtom)
for index, atom in atoms do
local function listener(state: unknown)
-- Remove the listener if the derived atom has been garbage collected,
-- typically because no subscriptions to it exist.
local derivedAtom = getDerivedAtom()
if derivedAtom then
dependencies[index] = state
derivedAtom:setForced(combiner(table.unpack(dependencies, 1, size)))
else
atom.listeners[listener] = nil
end
end
-- Tie the lifetime of the derived atom to the lifetime of its dependencies
-- without creating a strong reference cycle. As long as an atom is in use,
-- its derived atoms will be in use as well.
derivedAtom.owners[atom] = true
atom.listeners[listener] = true
end
return derivedAtom
end
return derive :: Derive

89
src/index.d.ts vendored Normal file
View file

@ -0,0 +1,89 @@
export = Charm;
export as namespace Charm;
declare namespace Charm {
interface InternalAtom<State> {
/** @hidden */
readonly listeners: Set<(value: State) => void>;
/** @hidden */
readonly debugTraceback: string;
/** @hidden */
readonly debugLabel: string;
/** @hidden */
setForced(state: State | ((current: State) => State)): void;
}
interface BaseAtom<State> extends InternalAtom<State> {
get(): State;
get<Result>(selector: (state: State) => Result): Result;
set(state: State | ((current: State) => State)): void;
memo(equalityFn: (previous: State, current: State) => boolean): this;
writable(onSet?: (update: State, previous: State) => State | void): Atom<State>;
readonly(): ReadonlyAtom<State>;
named(label: string): this;
unmount(): void;
}
interface Atom<State> extends BaseAtom<State> {
(): State;
(state: State | ((previous: State) => State)): void;
}
interface ReadonlyAtom<State> extends BaseAtom<State> {
(): State;
set: never;
}
type AtomStates<T extends Atom<any>[]> = {
[Key in keyof T]: T[Key] extends Atom<infer State> ? State : never;
};
function atom<State>(state: State): Atom<State>;
function isAtom(value: unknown): value is Atom<any>;
function derive<Atoms extends Atom<any>[], Result>(
...args: [...atoms: Atoms, combiner: (...atoms: AtomStates<Atoms>) => Result]
): ReadonlyAtom<Result>;
function subscribe<State>(atom: Atom<State>, callback: (state: State) => void): () => void;
function observe<K, V>(
atom: Atom<Map<K, V> | ReadonlyMap<K, V>>,
factory: (value: V, key: K) => (() => void) | void,
): () => void;
function observe(atom: Atom<any>, factory: (value: unknown, key: unknown) => (() => void) | void): () => void;
function splitAtom<Item, Key, Id = Key>(
listAtom: Atom<ReadonlyMap<Key, Item>> | Atom<Map<Key, Item>>,
keyExtractor?: (item: Item) => Id,
): Atom<ReadonlyMap<Id, Atom<Item>>>;
function splitAtom<Item>(listAtom: Atom<readonly Item[]>): Atom<readonly Atom<Item>[]>;
function splitAtom<Item, Id = number>(
listAtom: Atom<readonly Item[]>,
keyExtractor?: (item: Item) => Id,
): Atom<ReadonlyMap<Id, Atom<Item>>>;
function splitAtom<Item, Key extends string | number | symbol, Id = Key>(
listAtom: Atom<{ readonly [K in Key]?: Item }>,
keyExtractor?: (item: Item) => Id,
): Atom<ReadonlyMap<Id, Atom<Item>>>;
function useAtomState<State>(atom: Atom<State>): State;
function useAtomState<State, Result>(atom: Atom<State>, selector: (state: State) => Result): Result;
function useSetAtom<State>(atom: Atom<State>): (state: State | ((previous: State) => State)) => void;
function useAtom<State>(
atom: Atom<State>,
): LuaTuple<[state: State, setState: (state: State | ((previous: State) => State)) => void]>;
function useAtom<State, Result>(
atom: Atom<State>,
selector: (state: State) => Result,
): LuaTuple<[state: Result, setState: (state: Result | ((previous: Result) => Result)) => void]>;
}

26
src/init.luau Normal file
View file

@ -0,0 +1,26 @@
_G[script.modules.Promise] = _G[script]
local atom = require(script.atom)
local derive = require(script.derive)
local isAtom = require(script.isAtom)
local observe = require(script.observe)
local split = require(script.split)
local subscribe = require(script.subscribe)
local useAtom = require(script.react.useAtom)
local useAtomState = require(script.react.useAtomState)
local useSetAtom = require(script.react.useSetAtom)
export type Atom<State> = atom.Atom<State>
export type ReadonlyAtom<State> = atom.ReadonlyAtom<State>
return {
atom = atom,
derive = derive,
isAtom = isAtom,
observe = observe,
split = split,
subscribe = subscribe,
useAtom = useAtom,
useAtomState = useAtomState,
useSetAtom = useSetAtom,
}

5
src/isAtom.luau Normal file
View file

@ -0,0 +1,5 @@
local function isAtom(value: any)
return type(value) == "table" and getmetatable(value) and getmetatable(value).__type == "Atom" or false
end
return isAtom

9
src/modules/Promise.luau Normal file
View file

@ -0,0 +1,9 @@
local Package = script.Parent.Parent
if _G[script] then
return _G[script].Promise
end
assert(Package.Parent:FindFirstChild("Promise"), `Could not find Promise in {Package.Parent:GetFullName()}`)
return require(Package.Parent.Promise)

8
src/modules/React.luau Normal file
View file

@ -0,0 +1,8 @@
local Package = script.Parent.Parent
local Packages = if Package.Parent:FindFirstChild("React")
then Package.Parent
else Package.Parent:FindFirstChild("ReactLua")
type React = typeof(require(Package.Parent.React))
return (if Packages then require(Packages.React) else nil) :: React

View file

@ -0,0 +1,8 @@
local Package = script.Parent.Parent
local Packages = if Package.Parent:FindFirstChild("ReactRoblox")
then Package.Parent
else Package.Parent:FindFirstChild("ReactLua")
type ReactRoblox = typeof(require(Package.Parent.ReactRoblox))
return (if Packages then require(Packages.ReactRoblox) else nil) :: ReactRoblox

38
src/observe.luau Normal file
View file

@ -0,0 +1,38 @@
local atom = require(script.Parent.atom)
type Atom<T> = atom.Atom<T>
local subscribe = require(script.Parent.subscribe)
local function observe<Item, Key>(atom: Atom<{ [Key]: Item }>, factory: (value: Item, key: Key) -> () -> ()): (() -> (), () -> ())
local connections: { [Key]: () -> () } = {}
local function update(state: { [Key]: Item })
for key, disconnect in connections do
if state[key] == nil then
connections[key] = nil
disconnect()
end
end
for key, value in state do
if not connections[key] then
connections[key] = factory(value, key)
end
end
end
local unsubscribe, flush = subscribe(atom, update)
local function cleanup()
unsubscribe()
for _, disconnect in connections do
disconnect()
end
end
update(atom:get())
return cleanup, flush
end
return observe

19
src/react/useAtom.luau Normal file
View file

@ -0,0 +1,19 @@
local atom = require(script.Parent.Parent.atom)
type Atom<T> = atom.Atom<T>
local useAtomState = require(script.Parent.useAtomState)
local useSetAtom = require(script.Parent.useSetAtom)
type UseAtom =
(<State>(atom: Atom<State>) -> (State, useSetAtom.SetState<State>))
& (<State, Result>(
atom: Atom<State>,
selector: ((State) -> Result)?
) -> (State | Result, useSetAtom.SetState<State | Result>))
& (<State, Result>(atom: Atom<State>, selector: (State) -> Result) -> (Result, useSetAtom.SetState<State>))
local function useAtom<State>(atom: Atom<State>, selector: ((State) -> State)?): (State, useSetAtom.SetState<State>)
return useAtomState(atom, selector), useSetAtom(atom)
end
return useAtom :: UseAtom

View file

@ -0,0 +1,35 @@
local React = require(script.Parent.Parent.modules.React)
local atom = require(script.Parent.Parent.atom)
type Atom<T> = atom.Atom<T>
type UseAtomState =
(<State>(atom: Atom<State>) -> State)
& (<State, Result>(atom: Atom<State>, ((State) -> Result)?) -> State | Result)
& <State, Result>(atom: Atom<State>, selector: (State) -> Result) -> Result
local function useAtomState<State>(atom: Atom<State>, selector: ((State) -> State)?): State
local state, setState = React.useState(function()
return atom:get(selector :: (State) -> State)
end)
local latestSelector = React.useRef(selector)
latestSelector.current = selector
React.useEffect(function()
local function listener(state: State)
local selector = latestSelector.current
setState(if selector then selector(state) else state)
end
atom.listeners[listener] = true
return function()
atom.listeners[listener] = nil
end
end, { atom })
return state
end
return useAtomState :: UseAtomState

14
src/react/useSetAtom.luau Normal file
View file

@ -0,0 +1,14 @@
local React = require(script.Parent.Parent.modules.React)
local atom = require(script.Parent.Parent.atom)
type Atom<T> = atom.Atom<T>
export type SetState<T> = (T | ((T) -> T)) -> ()
local function useSetAtom<State>(atom: Atom<State>): SetState<State>
return React.useCallback(function(value)
atom:set(value)
end, { atom })
end
return useSetAtom

75
src/split.luau Normal file
View file

@ -0,0 +1,75 @@
local atom = require(script.Parent.atom)
type Atom<T> = atom.Atom<T>
type ReadonlyAtom<T> = atom.ReadonlyAtom<T>
local reference = require(script.Parent.utils.reference)
type Split =
(<Item, Key>(atom: Atom<{ [Key]: Item }>) -> ReadonlyAtom<{ [Key]: Atom<Item> }>)
& (<Item, Key>(atom: Atom<{ [any]: Item }>, keyExtractor: (Item) -> Key) -> ReadonlyAtom<{ [Key]: Atom<Item> }>)
local function split<Item, Key>(
itemsAtom: Atom<{ [Key]: Item }>,
keyExtractor: ((Item) -> Key)?
): ReadonlyAtom<{ [Key]: Atom<Item> }>
local splitAtom: ReadonlyAtom<{ [Key]: Atom<Item> }> = atom({}):readonly()
local getSplitAtom = reference.weak(splitAtom)
local previousAtoms: { [Key]: Atom<Item> } = {}
local previousItems = itemsAtom:get()
local listener: ({ [Key]: Item }) -> ()
local function cleanup()
itemsAtom.listeners[listener] = nil
for _, atom in previousAtoms do
atom:unmount()
end
end
function listener(items)
local splitAtom = getSplitAtom()
if not splitAtom then
return cleanup()
end
local atoms = table.clone(splitAtom:get())
local itemsByKeys: { [Key]: Item } = {}
for key, item in items do
local id = if keyExtractor then keyExtractor(item) else key
itemsByKeys[id] = item
if atoms[id] then
-- Force set the atom to avoid redundantly updating itemsAtom
atoms[id]:setForced(item)
else
atoms[id] = atom(item):writable(function(update)
local nextItems = table.clone(itemsAtom:get())
nextItems[key] = update
itemsAtom:set(nextItems)
end)
end
end
for key, item in previousItems do
local id = if keyExtractor then keyExtractor(item) else key
if itemsByKeys[id] == nil then
atoms[id]:unmount()
atoms[id] = nil
end
end
previousAtoms, previousItems = atoms, items
splitAtom:setForced(atoms)
end
itemsAtom.listeners[listener] = true
listener(itemsAtom:get())
return splitAtom
end
return (split :: any) :: Split

46
src/subscribe.luau Normal file
View file

@ -0,0 +1,46 @@
local atom = require(script.Parent.atom)
type Atom<T> = atom.Atom<T>
local reference = require(script.Parent.utils.reference)
local function subscribe<State>(atom: Atom<State>, callback: (state: State, lastState: State) -> ()): (() -> (), () -> ())
local pending: thread? = nil
local state = atom:get()
local function emit()
local lastState = state
state = atom:get()
callback(state, lastState)
end
local function update()
pending = pending or task.defer(function()
pending = nil
emit()
end)
end
local function flush()
if pending then
pending = task.cancel(pending)
emit()
end
end
local function unsubscribe()
reference.release(atom)
atom.listeners[update] = nil
if pending then
pending = task.cancel(pending)
end
end
-- Ensure the atom is held in memory while the subscription is active
reference.hold(atom)
atom.listeners[update] = true
return unsubscribe, flush
end
return subscribe

31
src/utils/reference.luau Normal file
View file

@ -0,0 +1,31 @@
local refs = {}
--- Prevents the garbage collector from collecting a value
local function hold(value: unknown)
refs[value] = true
end
--- Allows the garbage collector to collect a value
local function release(value: unknown)
refs[value] = nil
end
--- Clears all held references
local function clear()
table.clear(refs)
end
--- Returns a function that retrieves the value through a weak reference
local function weak<T>(value: T): () -> T?
local set = setmetatable({ value }, { __mode = "v" })
return function()
return set[1]
end
end
return {
hold = hold,
release = release,
clear = clear,
weak = weak,
}

1
testez-companion.toml Normal file
View file

@ -0,0 +1 @@
roots = ["ReplicatedStorage/Packages/charm/__tests__"]

53
testez.yml Normal file
View file

@ -0,0 +1,53 @@
---
globals:
FIXME:
args:
- required: false
type: string
FOCUS:
args: []
SKIP:
args: []
afterAll:
args:
- type: function
afterEach:
args:
- type: function
beforeAll:
args:
- type: function
beforeEach:
args:
- type: function
describe:
args:
- type: string
- type: function
describeFOCUS:
args:
- type: string
- type: function
describeSKIP:
args:
- type: string
- type: function
expect:
args:
- type: any
it:
args:
- type: string
- type: function
itFIXME:
args:
- type: string
- type: function
itFOCUS:
args:
- type: string
- type: function
itSKIP:
args:
- type: string
- type: function

27
tsconfig.json Normal file
View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
// required
"allowSyntheticDefaultImports": true,
"downlevelIteration": true,
"jsx": "react",
"jsxFactory": "React.createElement",
"jsxFragmentFactory": "React.Fragment",
"module": "commonjs",
"moduleResolution": "Node",
"noLib": true,
"resolveJsonModule": true,
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true,
"moduleDetection": "force",
"strict": true,
"target": "ESNext",
"typeRoots": ["node_modules/@rbxts"],
// configurable
"rootDir": "src",
"outDir": "out",
"incremental": true,
"tsBuildInfoFile": "out/tsconfig.tsbuildinfo",
"declaration": true
}
}

103
wally.lock Normal file
View file

@ -0,0 +1,103 @@
# This file is automatically @generated by Wally.
# It is not intended for manual editing.
registry = "test"
[[package]]
name = "evaera/promise"
version = "4.0.0"
dependencies = []
[[package]]
name = "jsdotlua/boolean"
version = "1.2.6"
dependencies = [["number", "jsdotlua/number@1.2.6"]]
[[package]]
name = "jsdotlua/collections"
version = "1.2.6"
dependencies = [["es7-types", "jsdotlua/es7-types@1.2.6"], ["instance-of", "jsdotlua/instance-of@1.2.6"]]
[[package]]
name = "jsdotlua/console"
version = "1.2.6"
dependencies = [["collections", "jsdotlua/collections@1.2.6"]]
[[package]]
name = "jsdotlua/es7-types"
version = "1.2.6"
dependencies = []
[[package]]
name = "jsdotlua/instance-of"
version = "1.2.6"
dependencies = []
[[package]]
name = "jsdotlua/luau-polyfill"
version = "1.2.6"
dependencies = [["boolean", "jsdotlua/boolean@1.2.6"], ["collections", "jsdotlua/collections@1.2.6"], ["console", "jsdotlua/console@1.2.6"], ["es7-types", "jsdotlua/es7-types@1.2.6"], ["instance-of", "jsdotlua/instance-of@1.2.6"], ["math", "jsdotlua/math@1.2.6"], ["number", "jsdotlua/number@1.2.6"], ["string", "jsdotlua/string@1.2.6"], ["symbol-luau", "jsdotlua/symbol-luau@1.0.1"], ["timers", "jsdotlua/timers@1.2.6"]]
[[package]]
name = "jsdotlua/math"
version = "1.2.6"
dependencies = []
[[package]]
name = "jsdotlua/number"
version = "1.2.6"
dependencies = []
[[package]]
name = "jsdotlua/promise"
version = "3.5.2"
dependencies = []
[[package]]
name = "jsdotlua/react"
version = "17.1.0"
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.1.0"]]
[[package]]
name = "jsdotlua/react-reconciler"
version = "17.1.0"
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["promise", "jsdotlua/promise@3.5.2"], ["react", "jsdotlua/react@17.1.0"], ["scheduler", "jsdotlua/scheduler@17.1.0"], ["shared", "jsdotlua/shared@17.1.0"]]
[[package]]
name = "jsdotlua/react-roblox"
version = "17.1.0"
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["react", "jsdotlua/react@17.1.0"], ["react-reconciler", "jsdotlua/react-reconciler@17.1.0"], ["scheduler", "jsdotlua/scheduler@17.1.0"], ["shared", "jsdotlua/shared@17.1.0"]]
[[package]]
name = "jsdotlua/scheduler"
version = "17.1.0"
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.1.0"]]
[[package]]
name = "jsdotlua/shared"
version = "17.1.0"
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"]]
[[package]]
name = "jsdotlua/string"
version = "1.2.6"
dependencies = [["es7-types", "jsdotlua/es7-types@1.2.6"], ["number", "jsdotlua/number@1.2.6"]]
[[package]]
name = "jsdotlua/symbol-luau"
version = "1.0.1"
dependencies = []
[[package]]
name = "jsdotlua/timers"
version = "1.2.6"
dependencies = [["collections", "jsdotlua/collections@1.2.6"]]
[[package]]
name = "littensy/charm"
version = "0.1.0"
dependencies = [["Promise", "evaera/promise@4.0.0"], ["React", "jsdotlua/react@17.1.0"], ["ReactRoblox", "jsdotlua/react-roblox@17.1.0"], ["TestEZ", "roblox/testez@0.4.1"]]
[[package]]
name = "roblox/testez"
version = "0.4.1"
dependencies = []

20
wally.toml Normal file
View file

@ -0,0 +1,20 @@
[package]
name = "littensy/charm"
description = "Atomic state management for Roblox"
version = "0.1.0"
license = "MIT"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = ["**"]
include = ["src", "src/**", "default.project.json", "wally.toml", "LICENSE.md"]
[place]
shared-packages = "game.ReplicatedStorage.Packages"
[dependencies]
React = "jsdotlua/react@17.1.0"
ReactRoblox = "jsdotlua/react-roblox@17.1.0"
Promise = "evaera/promise@4.0.0"
[dev-dependencies]
TestEZ = "roblox/testez@0.4.1"