mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
feat: initial commit
This commit is contained in:
commit
18723c1377
50 changed files with 3238 additions and 0 deletions
26
.eslintrc
Normal file
26
.eslintrc
Normal 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
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
* text=auto eol=lf
|
||||
*.luau linguist-language=Lua
|
||||
36
.github/workflows/ci.yml
vendored
Normal file
36
.github/workflows/ci.yml
vendored
Normal 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
29
.github/workflows/release.yml
vendored
Normal 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
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/node_modules
|
||||
/Packages
|
||||
/DevPackages
|
||||
/sourcemap.json
|
||||
*.tsbuildinfo
|
||||
*.tgz
|
||||
.DS_Store
|
||||
3
.luaurc
Normal file
3
.luaurc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"languageMode": "strict"
|
||||
}
|
||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"useTabs": true
|
||||
}
|
||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal 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
7
LICENSE.md
Normal 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
142
README.md
Normal 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">
|
||||
|
||||

|
||||
[](https://www.npmjs.com/package/@rbxts/charm)
|
||||
[](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">
|
||||
|
||||
[](LICENSE.md)
|
||||
|
||||
</div>
|
||||
11
aftman.toml
Normal file
11
aftman.toml
Normal 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
18
bin/check.sh
Normal 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
4
bin/install.sh
Normal 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
10
bin/spec.server.luau
Normal 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
24
bin/testez.d.luau
Normal 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
6
default.project.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "charm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
20
dev.project.json
Normal file
20
dev.project.json
Normal 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
BIN
images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 247 KiB |
58
package.json
Normal file
58
package.json
Normal 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
1482
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
5
selene.toml
Normal file
5
selene.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
std = "roblox+testez"
|
||||
|
||||
[lints]
|
||||
global_usage = "allow"
|
||||
shadowing = "allow"
|
||||
85
src/__tests__/atom.spec.luau
Normal file
85
src/__tests__/atom.spec.luau
Normal 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
|
||||
92
src/__tests__/derive.spec.luau
Normal file
92
src/__tests__/derive.spec.luau
Normal 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
|
||||
7
src/__tests__/init.spec.luau
Normal file
7
src/__tests__/init.spec.luau
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
local reference = require(script.Parent.Parent.utils.reference)
|
||||
|
||||
return function()
|
||||
afterEach(function()
|
||||
reference.clear()
|
||||
end)
|
||||
end
|
||||
33
src/__tests__/observe.spec.luau
Normal file
33
src/__tests__/observe.spec.luau
Normal 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
|
||||
42
src/__tests__/react/helpers/renderHook.luau
Normal file
42
src/__tests__/react/helpers/renderHook.luau
Normal 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
|
||||
27
src/__tests__/react/useAtomState.spec.luau
Normal file
27
src/__tests__/react/useAtomState.spec.luau
Normal 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
|
||||
34
src/__tests__/react/useSetAtom.spec.luau
Normal file
34
src/__tests__/react/useSetAtom.spec.luau
Normal 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
|
||||
128
src/__tests__/split.spec.luau
Normal file
128
src/__tests__/split.spec.luau
Normal 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
|
||||
92
src/__tests__/subscribe.spec.luau
Normal file
92
src/__tests__/subscribe.spec.luau
Normal 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
137
src/atom.luau
Normal 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
53
src/derive.luau
Normal 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
89
src/index.d.ts
vendored
Normal 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
26
src/init.luau
Normal 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
5
src/isAtom.luau
Normal 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
9
src/modules/Promise.luau
Normal 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
8
src/modules/React.luau
Normal 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
|
||||
8
src/modules/ReactRoblox.luau
Normal file
8
src/modules/ReactRoblox.luau
Normal 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
38
src/observe.luau
Normal 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
19
src/react/useAtom.luau
Normal 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
|
||||
35
src/react/useAtomState.luau
Normal file
35
src/react/useAtomState.luau
Normal 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
14
src/react/useSetAtom.luau
Normal 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
75
src/split.luau
Normal 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
46
src/subscribe.luau
Normal 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
31
src/utils/reference.luau
Normal 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
1
testez-companion.toml
Normal file
|
|
@ -0,0 +1 @@
|
|||
roots = ["ReplicatedStorage/Packages/charm/__tests__"]
|
||||
53
testez.yml
Normal file
53
testez.yml
Normal 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
27
tsconfig.json
Normal 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
103
wally.lock
Normal 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
20
wally.toml
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue