mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
feat: add peek and roblox-ts types
This commit is contained in:
parent
d2979ebd4c
commit
708bc55de7
37 changed files with 363 additions and 1234 deletions
|
|
@ -1,24 +1,23 @@
|
|||
local suite = require("../../../../test/suite")
|
||||
|
||||
suite.describe("charm-sync", function()
|
||||
if not game then
|
||||
return suite.skip()
|
||||
end
|
||||
suite.describe("client", function()
|
||||
require("./client.spec")
|
||||
end)
|
||||
suite.describe("server", function()
|
||||
require("./server.spec")
|
||||
end)
|
||||
suite.describe("flatten", function()
|
||||
require("./flatten.spec")
|
||||
end)
|
||||
suite.describe("patch", function()
|
||||
require("./patch.spec")
|
||||
end)
|
||||
suite.describe("validate", function()
|
||||
require("./validate.spec")
|
||||
end)
|
||||
suite.describe("client", function()
|
||||
require("./client.spec")
|
||||
end)
|
||||
|
||||
suite.describe("server", function()
|
||||
require("./server.spec")
|
||||
end)
|
||||
|
||||
suite.describe("flatten", function()
|
||||
require("./flatten.spec")
|
||||
end)
|
||||
|
||||
suite.describe("patch", function()
|
||||
require("./patch.spec")
|
||||
end)
|
||||
|
||||
suite.describe("validate", function()
|
||||
require("./validate.spec")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
local Charm = require("../Charm")
|
||||
local validate = require("./validate")
|
||||
|
||||
local flags = Charm.flags
|
||||
|
||||
--- A special symbol that denotes the absence of a value. Used to represent
|
||||
--- deleted values in patches.
|
||||
local None = { __none = "__none" }
|
||||
|
|
@ -63,7 +65,7 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serial
|
|||
patches = stringifySparseArray(patches)
|
||||
end
|
||||
|
||||
if serialize and Charm.strict.enabled then
|
||||
if serialize and flags.strict then
|
||||
for key, value in next, patches do
|
||||
validate(value, key)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -23,13 +23,13 @@ local function gc()
|
|||
end
|
||||
end
|
||||
|
||||
--- In alien-core, a computed value unlinks from dependencies when its last
|
||||
--- In alien-signals, a computed value unlinks from dependencies when its last
|
||||
--- parent scope is disposed. Calling a computed value outside of a scope links
|
||||
--- it to dependencies, but the dependencies hold strong references to the
|
||||
--- computed value, so it won't GC until called in a scope and disposed.
|
||||
---
|
||||
--- This function releases a computed value by linking it to a scope and then
|
||||
--- immediately closing it, causing alien-core to unlink the computed value
|
||||
--- immediately closing it, causing alien-signals to unlink the computed value
|
||||
--- and allow it to be garbage collected.
|
||||
local function release(getter: () -> ())
|
||||
effectScope(function()
|
||||
|
|
|
|||
107
packages/charm/src/__tests__/peek.spec.luau
Normal file
107
packages/charm/src/__tests__/peek.spec.luau
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
local Charm = require("../")
|
||||
local suite = require("../../../../test/suite")
|
||||
|
||||
local atom = Charm.atom
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local onCleanup = Charm.onCleanup
|
||||
local peek = Charm.peek
|
||||
local test = suite.test
|
||||
|
||||
test("should not run detached", function()
|
||||
local a = atom(1)
|
||||
local spyCalls = 0
|
||||
|
||||
effect(function()
|
||||
a()
|
||||
peek(function()
|
||||
onCleanup(function()
|
||||
spyCalls += 1
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(spyCalls == 0, "should not be called yet")
|
||||
a(2)
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
a(3)
|
||||
assert(spyCalls == 2, "should be called twice")
|
||||
end)
|
||||
|
||||
test("should block tracking inside effects", function()
|
||||
local a = atom(1)
|
||||
local b = atom(2)
|
||||
local spyCalls = 0
|
||||
|
||||
effect(function()
|
||||
peek(function()
|
||||
spyCalls += 1
|
||||
return a() + b()
|
||||
end)
|
||||
end)
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
|
||||
a(10)
|
||||
b(20)
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
end)
|
||||
|
||||
test("should block tracking even when run inside effect run inside peek", function()
|
||||
local s = atom(1)
|
||||
local spyCalls = 0
|
||||
|
||||
peek(function()
|
||||
effect(function()
|
||||
peek(function()
|
||||
spyCalls += 1
|
||||
return s()
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
|
||||
s(2)
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
end)
|
||||
|
||||
test("should not cause atom assignments throw", function()
|
||||
local a = atom(1)
|
||||
local aChangedTime = atom(0)
|
||||
|
||||
local dispose = effect(function()
|
||||
a()
|
||||
peek(function()
|
||||
aChangedTime(aChangedTime() + 1)
|
||||
end)
|
||||
end)
|
||||
|
||||
a(2)
|
||||
assert(aChangedTime() == 2, "aChangedTime should be 2")
|
||||
a(3)
|
||||
assert(aChangedTime() == 3, "aChangedTime should be 3")
|
||||
|
||||
dispose()
|
||||
end)
|
||||
|
||||
test("should block tracking inside computed atoms", function()
|
||||
local a = atom(1)
|
||||
local b = atom(2)
|
||||
local spyCalls = 0
|
||||
local c = computed(function()
|
||||
return peek(function()
|
||||
spyCalls += 1
|
||||
return a() + b()
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(spyCalls == 0, "should not be called yet")
|
||||
assert(c() == 3, "c should be 3")
|
||||
a(10)
|
||||
c()
|
||||
b(20)
|
||||
c()
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
assert(c() == 3, "c should be 3")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
@ -24,22 +24,20 @@ suite.describe("core", function()
|
|||
end)
|
||||
end)
|
||||
|
||||
suite.describe("charm", function()
|
||||
suite.describe("listen", function()
|
||||
require("./listen.spec")
|
||||
end)
|
||||
suite.describe("mapped", function()
|
||||
require("./mapped.spec")
|
||||
end)
|
||||
suite.describe("observe", function()
|
||||
require("./observe.spec")
|
||||
end)
|
||||
suite.describe("subscribe", function()
|
||||
require("./subscribe.spec")
|
||||
end)
|
||||
suite.describe("untracked", function()
|
||||
require("./untracked.spec")
|
||||
end)
|
||||
suite.describe("listen", function()
|
||||
require("./listen.spec")
|
||||
end)
|
||||
suite.describe("mapped", function()
|
||||
require("./mapped.spec")
|
||||
end)
|
||||
suite.describe("observe", function()
|
||||
require("./observe.spec")
|
||||
end)
|
||||
suite.describe("subscribe", function()
|
||||
require("./subscribe.spec")
|
||||
end)
|
||||
suite.describe("untracked", function()
|
||||
require("./untracked.spec")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,29 @@ local atom = Charm.atom
|
|||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local untracked = Charm.untracked
|
||||
local onCleanup = Charm.onCleanup
|
||||
local test = suite.test
|
||||
|
||||
test("should run detached", function()
|
||||
local a = atom(1)
|
||||
local spyCalls = 0
|
||||
|
||||
effect(function()
|
||||
a()
|
||||
untracked(function()
|
||||
onCleanup(function()
|
||||
spyCalls += 1
|
||||
end, true)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(spyCalls == 0, "should not be called")
|
||||
a(2)
|
||||
assert(spyCalls == 0, "rerun should not call cleanup")
|
||||
a(3)
|
||||
assert(spyCalls == 0, "rerun should not call cleanup")
|
||||
end)
|
||||
|
||||
test("should block tracking inside effects", function()
|
||||
local a = atom(1)
|
||||
local b = atom(2)
|
||||
|
|
|
|||
|
|
@ -1,32 +1,4 @@
|
|||
--[[
|
||||
alien-signals 2.0.5 implementation adopted from
|
||||
https://github.com/Nicell/alien-signals-luau
|
||||
https://github.com/stackblitz/alien-signals
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-present Johnson Chu, Nicholas Winans
|
||||
|
||||
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.
|
||||
]]
|
||||
|
||||
local strict = require("./strict")
|
||||
local config = require("./flags")
|
||||
local system = require("./system")
|
||||
local utils = require("./utils")
|
||||
|
||||
|
|
@ -103,15 +75,15 @@ local function setCurrentScope(scope: Effect | EffectScope?)
|
|||
end
|
||||
|
||||
local function setCurrentTracking(tracking: boolean): boolean
|
||||
local prevTracking = activeSubTracking
|
||||
local prevSubTracking = activeSubTracking
|
||||
activeSubTracking = tracking
|
||||
return prevTracking
|
||||
return prevSubTracking
|
||||
end
|
||||
|
||||
local function runCleanups(effect: EffectScope)
|
||||
for _, cleanup in effect.cleanups do
|
||||
local prev = setCurrentSub(nil)
|
||||
local success, result: unknown = (if strict.enabled then noYield else pcall)(cleanup)
|
||||
local success, result: unknown = (if config.strict then noYield else pcall)(cleanup)
|
||||
setCurrentSub(prev)
|
||||
if not success then
|
||||
error(result)
|
||||
|
|
@ -136,7 +108,7 @@ local function run(effect: Effect | EffectScope, flags: ReactiveFlags): ()
|
|||
local prev = setCurrentSub(effect)
|
||||
local prevTracking = setCurrentTracking(true)
|
||||
startTracking(effect)
|
||||
local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)((effect :: Effect).fn)
|
||||
local success, result: (() -> ())? = (if config.strict then noYield else pcall)((effect :: Effect).fn)
|
||||
setCurrentSub(prev)
|
||||
endTracking(effect)
|
||||
setCurrentTracking(prevTracking)
|
||||
|
|
@ -194,7 +166,7 @@ local function updateComputed(computed: Computed): boolean
|
|||
local prevTracking = setCurrentTracking(true)
|
||||
startTracking(computed)
|
||||
local oldValue = computed.value
|
||||
local success, result = (if strict.enabled then noYield else pcall)(computed.getter, oldValue)
|
||||
local success, result = (if config.strict then noYield else pcall)(computed.getter, oldValue)
|
||||
setCurrentSub(prevSub)
|
||||
endTracking(computed)
|
||||
setCurrentTracking(prevTracking)
|
||||
|
|
@ -262,7 +234,7 @@ local function atomGetterSetter<T>(atom: Atom<T>): (...((T) -> T) | T) -> T
|
|||
local newValue = ...
|
||||
if type(newValue) == "function" then
|
||||
local prev = setCurrentSub(nil)
|
||||
local success, result = (if strict.enabled then noYield else pcall)(newValue, oldValue)
|
||||
local success, result = (if config.strict then noYield else pcall)(newValue, oldValue)
|
||||
setCurrentSub(prev)
|
||||
if not success then
|
||||
error(result)
|
||||
|
|
@ -380,7 +352,7 @@ local function effect(fn: () -> ...(() -> ())?): () -> ()
|
|||
end
|
||||
local prev = setCurrentSub(self)
|
||||
local prevTracking = setCurrentTracking(true)
|
||||
local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn)
|
||||
local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn)
|
||||
setCurrentSub(prev)
|
||||
setCurrentTracking(prevTracking)
|
||||
if not success then
|
||||
|
|
@ -401,7 +373,7 @@ local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () ->
|
|||
end
|
||||
local prevSub = setCurrentSub(nil)
|
||||
local prevScope = setCurrentScope(self)
|
||||
local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn)
|
||||
local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn)
|
||||
setCurrentScope(prevScope)
|
||||
setCurrentSub(prevSub)
|
||||
if not success then
|
||||
|
|
|
|||
|
|
@ -4,5 +4,5 @@ end
|
|||
|
||||
return {
|
||||
-- Enable strict mode in O2
|
||||
enabled = inlineTest() == "inlineTest",
|
||||
strict = inlineTest() == "inlineTest",
|
||||
}
|
||||
69
packages/charm/src/index.d.ts
vendored
69
packages/charm/src/index.d.ts
vendored
|
|
@ -1 +1,68 @@
|
|||
export {};
|
||||
type Key = string | number | symbol;
|
||||
type Cleanup = () => void;
|
||||
|
||||
export interface Atom<T> {
|
||||
(): T;
|
||||
(value: T | ((value: T) => T)): T;
|
||||
}
|
||||
|
||||
export type Selector<T> = () => T;
|
||||
|
||||
export type Equals<T> = (current: T, incoming: T) => boolean;
|
||||
|
||||
export const flags: {
|
||||
strict: boolean;
|
||||
};
|
||||
|
||||
export function atom<T>(value: T, equals?: Equals<T>): Atom<T>;
|
||||
// Overload for undefined initial value
|
||||
export function atom<T>(): Atom<T | undefined>;
|
||||
|
||||
export function computed<T>(getter: (previousValue?: T) => T, equals?: Equals<T>): () => T;
|
||||
|
||||
export function effect(callback: () => Cleanup | void): Cleanup;
|
||||
|
||||
export function effectScope(callback: () => Cleanup | void, detached?: boolean): Cleanup;
|
||||
|
||||
export function listen<T>(
|
||||
callback: () => T,
|
||||
listener: (value: T, previousValue: T | undefined, dispose: Cleanup) => void,
|
||||
): Cleanup;
|
||||
|
||||
export function subscribe<T>(callback: () => T, listener: (value: T, previousValue: T) => void): Cleanup;
|
||||
|
||||
export function mapped<VI, KI, VO, KO = KI>(
|
||||
callback: () => Map<KI, VI> | ReadonlyMap<KI, VI>,
|
||||
mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
|
||||
): () => ReadonlyMap<KO, VO>;
|
||||
// Overload for arrays
|
||||
export function mapped<VI, VO, K extends Key = number>(
|
||||
callback: () => readonly VI[],
|
||||
mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO,
|
||||
): () => K extends number ? readonly VO[] : { readonly [P in K]: VO };
|
||||
// Overload for objects
|
||||
export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
|
||||
callback: () => { readonly [K in KI]: VI },
|
||||
mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
|
||||
): () => { readonly [K in KO]: VO };
|
||||
|
||||
export function observe<V, K = number>(
|
||||
callback: () => Map<K, V> | ReadonlyMap<K, V> | readonly V[],
|
||||
observer: (value: V, key: K) => Cleanup | void,
|
||||
): Cleanup;
|
||||
// Overload for objects
|
||||
export function observe<V, K extends Key>(
|
||||
callback: () => { readonly [K in Key]: V },
|
||||
observer: (value: V, key: K) => Cleanup | void,
|
||||
): Cleanup;
|
||||
|
||||
export function onCleanup(callback: Cleanup, failSilently?: boolean): void;
|
||||
|
||||
export function batched<Args extends any[], Result>(
|
||||
callback: (...args: Args) => Result,
|
||||
...args: Args
|
||||
): (...args: Args) => Result;
|
||||
|
||||
export function peek<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
|
||||
|
||||
export function untracked<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
local batched = require("@self/batched")
|
||||
local core = require("@self/core")
|
||||
local flags = require("@self/flags")
|
||||
local listen = require("@self/listen")
|
||||
local mapped = require("@self/mapped")
|
||||
local observe = require("@self/observe")
|
||||
local strict = require("@self/strict")
|
||||
local peek = require("@self/peek")
|
||||
local subscribe = require("@self/subscribe")
|
||||
local types = require("@self/types")
|
||||
local untracked = require("@self/untracked")
|
||||
|
|
@ -11,24 +12,19 @@ local untracked = require("@self/untracked")
|
|||
export type Atom<T = any> = types.Atom<T>
|
||||
export type Selector<T = any> = types.Selector<T>
|
||||
|
||||
local atom = core.atom
|
||||
local computed = core.computed
|
||||
local effect = core.effect
|
||||
local effectScope = core.effectScope
|
||||
local onCleanup = core.onCleanup
|
||||
|
||||
return {
|
||||
core = core,
|
||||
strict = strict,
|
||||
atom = atom,
|
||||
flags = flags,
|
||||
atom = core.atom,
|
||||
computed = core.computed,
|
||||
effect = core.effect,
|
||||
effectScope = core.effectScope,
|
||||
onCleanup = core.onCleanup,
|
||||
batched = batched,
|
||||
computed = computed,
|
||||
effect = effect,
|
||||
effectScope = effectScope,
|
||||
listen = listen,
|
||||
mapped = mapped,
|
||||
observe = observe,
|
||||
onCleanup = onCleanup,
|
||||
peek = peek,
|
||||
subscribe = subscribe,
|
||||
untracked = untracked,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
local core = require("./core")
|
||||
local untracked = require("./untracked")
|
||||
local peek = require("./peek")
|
||||
|
||||
local computed = core.computed
|
||||
local effect = core.effect
|
||||
|
|
@ -19,7 +19,7 @@ local function listen<T>(selector: () -> T, listener: (state: T, previous: T?, d
|
|||
dispose = effect(function()
|
||||
local previous = current
|
||||
current = tracked()
|
||||
untracked(listener, current, previous, dispose)
|
||||
peek(listener, current, previous, dispose)
|
||||
end)
|
||||
|
||||
if disposed then
|
||||
|
|
|
|||
18
packages/charm/src/peek.luau
Normal file
18
packages/charm/src/peek.luau
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
local core = require("./core")
|
||||
|
||||
local setCurrentTracking = core.setCurrentTracking
|
||||
|
||||
local function handlePeek(prevTracking: boolean, ok: boolean, ...)
|
||||
setCurrentTracking(prevTracking)
|
||||
if ok then
|
||||
return ...
|
||||
else
|
||||
error((...))
|
||||
end
|
||||
end
|
||||
|
||||
local function peek<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
|
||||
return handlePeek(setCurrentTracking(false), pcall(callback, ...))
|
||||
end
|
||||
|
||||
return peek
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
local core = require("./core")
|
||||
local untracked = require("./untracked")
|
||||
local peek = require("./peek")
|
||||
|
||||
local computed = core.computed
|
||||
local effect = core.effect
|
||||
|
|
@ -18,7 +18,7 @@ local function subscribe<T>(selector: () -> T, listener: (state: T, lastState: T
|
|||
if mounted then
|
||||
mounted = false
|
||||
else
|
||||
untracked(listener, current, previous)
|
||||
peek(listener, current, previous)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
local core = require("./core")
|
||||
|
||||
local setCurrentTracking = core.setCurrentTracking
|
||||
local setCurrentSub = core.setCurrentSub
|
||||
|
||||
local function handleUntracked(prevTracking: boolean, ok: boolean, ...)
|
||||
setCurrentTracking(prevTracking)
|
||||
local function handleUntracked(prevSub: core.ReactiveNode?, ok: boolean, ...)
|
||||
setCurrentSub(prevSub)
|
||||
if ok then
|
||||
return ...
|
||||
else
|
||||
|
|
@ -12,7 +12,7 @@ local function handleUntracked(prevTracking: boolean, ok: boolean, ...)
|
|||
end
|
||||
|
||||
local function untracked<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
|
||||
return handleUntracked(setCurrentTracking(false), pcall(callback, ...))
|
||||
return handleUntracked(setCurrentSub(nil), pcall(callback, ...))
|
||||
end
|
||||
|
||||
return untracked
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "charm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
local core = require("./core")
|
||||
|
||||
local startBatch = core.startBatch
|
||||
local endBatch = core.endBatch
|
||||
|
||||
local function handleBatched(ok: boolean, ...)
|
||||
endBatch()
|
||||
if ok then
|
||||
return ...
|
||||
else
|
||||
error((...))
|
||||
end
|
||||
end
|
||||
|
||||
local function batched<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
|
||||
startBatch()
|
||||
return handleBatched(pcall(callback, ...))
|
||||
end
|
||||
|
||||
return batched
|
||||
|
|
@ -1,445 +0,0 @@
|
|||
--[[
|
||||
alien-signals 2.0.5 implementation adopted from
|
||||
https://github.com/Nicell/alien-signals-luau
|
||||
https://github.com/stackblitz/alien-signals
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-present Johnson Chu, Nicholas Winans
|
||||
|
||||
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.
|
||||
]]
|
||||
|
||||
local strict = require("./strict")
|
||||
local system = require("./system")
|
||||
local utils = require("./utils")
|
||||
|
||||
export type ReactiveFlags = system.ReactiveFlags
|
||||
export type ReactiveNode = system.ReactiveNode
|
||||
export type Link = system.Link
|
||||
|
||||
local ReactiveFlags = system.ReactiveFlags
|
||||
local createReactiveSystem = system.createReactiveSystem
|
||||
local noYield = utils.noYield
|
||||
|
||||
local NONE = 0b0000000
|
||||
local MUTABLE = 0b0000001
|
||||
local WATCHING = 0b0000010
|
||||
local DIRTY = 0b0010000
|
||||
local PENDING = 0b0100000
|
||||
local QUEUED = 0b1000000
|
||||
|
||||
type EffectScope = ReactiveNode & {
|
||||
cleanups: { () -> () },
|
||||
}
|
||||
|
||||
type Effect = ReactiveNode & {
|
||||
fn: () -> ...(() -> ())?,
|
||||
cleanups: { () -> () },
|
||||
}
|
||||
|
||||
type Computed<T = any> = ReactiveNode & {
|
||||
value: T?,
|
||||
getter: (previousValue: T?) -> T,
|
||||
equals: ((T, T) -> boolean)?,
|
||||
}
|
||||
|
||||
type Atom<T = any> = ReactiveNode & {
|
||||
previousValue: T,
|
||||
value: T,
|
||||
equals: ((T, T) -> boolean)?,
|
||||
}
|
||||
|
||||
local link: (dep: ReactiveNode, sub: ReactiveNode) -> ()
|
||||
local unlink: (link: Link, sub: ReactiveNode?) -> Link?
|
||||
local propagate: (link: Link) -> ()
|
||||
local checkDirty: (link: Link, sub: ReactiveNode) -> boolean
|
||||
local endTracking: (sub: ReactiveNode) -> ()
|
||||
local startTracking: (sub: ReactiveNode) -> ()
|
||||
local shallowPropagate: (link: Link) -> ()
|
||||
|
||||
local batchDepth = 0
|
||||
local queuedEffects: { Effect | EffectScope? } = {}
|
||||
local queuedEffectsLength = 0
|
||||
local notifyIndex = 0
|
||||
local activeSubTracking = true
|
||||
local activeSub: ReactiveNode?
|
||||
local activeScope: Effect | EffectScope?
|
||||
|
||||
local function getCurrentSub(): ReactiveNode?
|
||||
return activeSub
|
||||
end
|
||||
|
||||
local function setCurrentSub(sub: ReactiveNode?)
|
||||
local prevSub = activeSub
|
||||
activeSub = sub
|
||||
return prevSub
|
||||
end
|
||||
|
||||
local function getCurrentScope(): Effect | EffectScope?
|
||||
return activeScope
|
||||
end
|
||||
|
||||
local function setCurrentScope(scope: Effect | EffectScope?)
|
||||
local prevScope = activeScope
|
||||
activeScope = scope
|
||||
return prevScope
|
||||
end
|
||||
|
||||
local function setCurrentTracking(tracking: boolean): boolean
|
||||
local prevTracking = activeSubTracking
|
||||
activeSubTracking = tracking
|
||||
return prevTracking
|
||||
end
|
||||
|
||||
local function runCleanups(effect: EffectScope)
|
||||
for _, cleanup in effect.cleanups do
|
||||
local prev = setCurrentSub(nil)
|
||||
local success, result: unknown = (if strict.enabled then noYield else pcall)(cleanup)
|
||||
setCurrentSub(prev)
|
||||
if not success then
|
||||
error(result)
|
||||
end
|
||||
end
|
||||
table.clear(effect.cleanups)
|
||||
end
|
||||
|
||||
local function onCleanup(cleanup: () -> (), failSilently: boolean?)
|
||||
if activeSub then
|
||||
table.insert((activeSub :: Effect).cleanups, cleanup)
|
||||
elseif activeScope then
|
||||
table.insert(activeScope.cleanups, cleanup)
|
||||
elseif not failSilently then
|
||||
warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2))
|
||||
end
|
||||
end
|
||||
|
||||
local function run(effect: Effect | EffectScope, flags: ReactiveFlags): ()
|
||||
if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then
|
||||
runCleanups(effect)
|
||||
local prev = setCurrentSub(effect)
|
||||
local prevTracking = setCurrentTracking(true)
|
||||
startTracking(effect)
|
||||
local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)((effect :: Effect).fn)
|
||||
setCurrentSub(prev)
|
||||
endTracking(effect)
|
||||
setCurrentTracking(prevTracking)
|
||||
if not success then
|
||||
error(result)
|
||||
elseif result then
|
||||
table.insert(effect.cleanups, result)
|
||||
end
|
||||
return
|
||||
elseif bit32.btest(flags, PENDING) then
|
||||
(effect :: Effect).flags = bit32.band(flags, bit32.bnot(PENDING))
|
||||
end
|
||||
local link = effect.deps
|
||||
while link do
|
||||
local dep = link.dep
|
||||
local depFlags = dep.flags
|
||||
if bit32.btest(depFlags, QUEUED) then
|
||||
depFlags = bit32.band(depFlags, bit32.bnot(QUEUED))
|
||||
dep.flags = depFlags
|
||||
run(dep :: Effect, depFlags)
|
||||
end
|
||||
link = link.nextDep
|
||||
end
|
||||
end
|
||||
|
||||
local function flush(): ()
|
||||
while notifyIndex < queuedEffectsLength do
|
||||
notifyIndex += 1
|
||||
local effect = queuedEffects[notifyIndex] :: Effect
|
||||
queuedEffects[notifyIndex] = nil
|
||||
effect.flags = bit32.band(effect.flags, bit32.bnot(QUEUED))
|
||||
run(effect, effect.flags)
|
||||
end
|
||||
notifyIndex = 0
|
||||
queuedEffectsLength = 0
|
||||
end
|
||||
|
||||
local function getBatchDepth(): number
|
||||
return batchDepth
|
||||
end
|
||||
|
||||
local function startBatch()
|
||||
batchDepth += 1
|
||||
end
|
||||
|
||||
local function endBatch()
|
||||
batchDepth -= 1
|
||||
if batchDepth == 0 then
|
||||
flush()
|
||||
end
|
||||
end
|
||||
|
||||
local function updateComputed(computed: Computed): boolean
|
||||
local prevSub = setCurrentSub(computed)
|
||||
local prevTracking = setCurrentTracking(true)
|
||||
startTracking(computed)
|
||||
local oldValue = computed.value
|
||||
local success, result = (if strict.enabled then noYield else pcall)(computed.getter, oldValue)
|
||||
setCurrentSub(prevSub)
|
||||
endTracking(computed)
|
||||
setCurrentTracking(prevTracking)
|
||||
if not success then
|
||||
error(result)
|
||||
end
|
||||
local equals = computed.equals
|
||||
if if equals then not equals(oldValue, result) else oldValue ~= result then
|
||||
computed.value = result
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
local function updateAtom(atom: Atom, value: any): boolean
|
||||
atom.flags = MUTABLE
|
||||
if atom.previousValue ~= value then
|
||||
atom.previousValue = value
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
local function readAtom<T>(atom: Atom<T>): T
|
||||
local value = atom.value
|
||||
if bit32.btest(atom.flags, DIRTY) then
|
||||
if updateAtom(atom, value) then
|
||||
local subs = atom.subs
|
||||
if subs then
|
||||
shallowPropagate(subs)
|
||||
end
|
||||
end
|
||||
end
|
||||
if activeSub and activeSubTracking then
|
||||
link(atom, activeSub)
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
local function writeAtom<T>(atom: Atom<T>, oldValue: T, newValue: T): T
|
||||
local equals = atom.equals
|
||||
if if equals then not equals(oldValue, newValue :: T) else oldValue ~= newValue then
|
||||
atom.value = newValue
|
||||
atom.flags = bit32.bor(MUTABLE, DIRTY)
|
||||
local subs = atom.subs
|
||||
if subs then
|
||||
propagate(subs)
|
||||
if batchDepth == 0 then
|
||||
flush()
|
||||
end
|
||||
end
|
||||
return newValue
|
||||
end
|
||||
return oldValue
|
||||
end
|
||||
|
||||
local function atomGetterSetter<T>(atom: Atom<T>): (...((T) -> T) | T) -> T
|
||||
return function(...)
|
||||
if select("#", ...) == 0 then
|
||||
return readAtom(atom)
|
||||
end
|
||||
local oldValue = atom.value
|
||||
local newValue = ...
|
||||
if type(newValue) == "function" then
|
||||
local prev = setCurrentSub(nil)
|
||||
local success, result = (if strict.enabled then noYield else pcall)(newValue, oldValue)
|
||||
setCurrentSub(prev)
|
||||
if not success then
|
||||
error(result)
|
||||
end
|
||||
newValue = result
|
||||
end
|
||||
return writeAtom(atom, oldValue, newValue :: T)
|
||||
end
|
||||
end
|
||||
|
||||
local function computedGetter<T>(computed: Computed<T>): () -> T
|
||||
return function()
|
||||
local flags = computed.flags
|
||||
if
|
||||
bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(computed.deps :: Link, computed))
|
||||
then
|
||||
if updateComputed(computed) then
|
||||
local subs = computed.subs
|
||||
if subs then
|
||||
shallowPropagate(subs)
|
||||
end
|
||||
end
|
||||
elseif bit32.btest(flags, PENDING) then
|
||||
computed.flags = bit32.band(flags, bit32.bnot(PENDING))
|
||||
end
|
||||
if activeSub then
|
||||
if activeSubTracking then
|
||||
link(computed, activeSub)
|
||||
end
|
||||
elseif activeScope then
|
||||
link(computed, activeScope)
|
||||
end
|
||||
return computed.value :: T
|
||||
end
|
||||
end
|
||||
|
||||
local function effectStop(self: Effect | EffectScope): () -> ()
|
||||
return function()
|
||||
local dep = self.deps
|
||||
while dep do
|
||||
dep = unlink(dep, self)
|
||||
end
|
||||
local sub = self.subs
|
||||
if sub then
|
||||
unlink(sub)
|
||||
end
|
||||
(self :: Effect).flags = NONE
|
||||
runCleanups(self)
|
||||
end
|
||||
end
|
||||
|
||||
local function update(atom: Atom | Computed): boolean
|
||||
if (atom :: Computed).getter then
|
||||
return updateComputed(atom :: Computed)
|
||||
else
|
||||
return updateAtom(atom :: Atom, atom.value)
|
||||
end
|
||||
end
|
||||
|
||||
local function notify(effect: Effect | EffectScope)
|
||||
local flags = effect.flags
|
||||
if not bit32.btest(flags, QUEUED) then
|
||||
(effect :: Effect).flags = bit32.bor(flags, QUEUED)
|
||||
local subs = effect.subs
|
||||
if subs then
|
||||
notify(subs.sub :: Effect)
|
||||
else
|
||||
queuedEffectsLength += 1
|
||||
queuedEffects[queuedEffectsLength] = effect
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function unwatched(node: Atom | Computed | Effect | EffectScope)
|
||||
if (node :: Computed).getter then
|
||||
local toRemove = node.deps
|
||||
if toRemove then
|
||||
(node :: Computed).flags = bit32.bor(MUTABLE, DIRTY)
|
||||
repeat
|
||||
toRemove = unlink(toRemove, node)
|
||||
until not toRemove
|
||||
end
|
||||
elseif (node :: Effect).cleanups then
|
||||
effectStop(node :: Effect)()
|
||||
end
|
||||
end
|
||||
|
||||
local function atom<T>(initialValue: T, equals: ((T, T) -> boolean)?): (...((T) -> T) | T) -> T
|
||||
return atomGetterSetter({
|
||||
previousValue = initialValue,
|
||||
value = initialValue,
|
||||
flags = MUTABLE,
|
||||
equals = equals,
|
||||
})
|
||||
end
|
||||
|
||||
local function computed<T>(getter: (previousValue: T?) -> T, equals: ((T, T) -> boolean)?): () -> T
|
||||
return computedGetter({
|
||||
flags = bit32.bor(MUTABLE, DIRTY),
|
||||
getter = getter,
|
||||
equals = equals,
|
||||
})
|
||||
end
|
||||
|
||||
local function effect(fn: () -> ...(() -> ())?): () -> ()
|
||||
local self: Effect = {
|
||||
fn = fn,
|
||||
flags = WATCHING,
|
||||
cleanups = {},
|
||||
}
|
||||
if activeSub then
|
||||
link(self, activeSub)
|
||||
elseif activeScope then
|
||||
link(self, activeScope)
|
||||
end
|
||||
local prev = setCurrentSub(self)
|
||||
local prevTracking = setCurrentTracking(true)
|
||||
local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn)
|
||||
setCurrentSub(prev)
|
||||
setCurrentTracking(prevTracking)
|
||||
if not success then
|
||||
error(result)
|
||||
elseif result then
|
||||
table.insert(self.cleanups, result)
|
||||
end
|
||||
return effectStop(self)
|
||||
end
|
||||
|
||||
local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> ()
|
||||
local self: EffectScope = {
|
||||
flags = NONE,
|
||||
cleanups = {},
|
||||
}
|
||||
if activeScope and not detached then
|
||||
link(self, activeScope)
|
||||
end
|
||||
local prevSub = setCurrentSub(nil)
|
||||
local prevScope = setCurrentScope(self)
|
||||
local success, result: (() -> ())? = (if strict.enabled then noYield else pcall)(fn)
|
||||
setCurrentScope(prevScope)
|
||||
setCurrentSub(prevSub)
|
||||
if not success then
|
||||
error(result)
|
||||
elseif result then
|
||||
table.insert(self.cleanups, result)
|
||||
end
|
||||
return effectStop(self)
|
||||
end
|
||||
|
||||
local reactiveSystem = createReactiveSystem({
|
||||
update = update :: (ReactiveNode) -> boolean,
|
||||
notify = notify :: (ReactiveNode) -> (),
|
||||
unwatched = unwatched :: (ReactiveNode) -> (),
|
||||
})
|
||||
|
||||
link = reactiveSystem.link
|
||||
unlink = reactiveSystem.unlink
|
||||
propagate = reactiveSystem.propagate
|
||||
checkDirty = reactiveSystem.checkDirty
|
||||
endTracking = reactiveSystem.endTracking
|
||||
startTracking = reactiveSystem.startTracking
|
||||
shallowPropagate = reactiveSystem.shallowPropagate
|
||||
|
||||
return {
|
||||
ReactiveFlags = ReactiveFlags,
|
||||
createReactiveSystem = createReactiveSystem,
|
||||
getCurrentSub = getCurrentSub,
|
||||
setCurrentSub = setCurrentSub,
|
||||
getCurrentScope = getCurrentScope,
|
||||
setCurrentScope = setCurrentScope,
|
||||
setCurrentTracking = setCurrentTracking,
|
||||
onCleanup = onCleanup,
|
||||
getBatchDepth = getBatchDepth,
|
||||
startBatch = startBatch,
|
||||
endBatch = endBatch,
|
||||
atom = atom,
|
||||
computed = computed,
|
||||
effect = effect,
|
||||
effectScope = effectScope,
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
local batched = require("@self/batched")
|
||||
local core = require("@self/core")
|
||||
local listen = require("@self/listen")
|
||||
local mapped = require("@self/mapped")
|
||||
local observe = require("@self/observe")
|
||||
local strict = require("@self/strict")
|
||||
local subscribe = require("@self/subscribe")
|
||||
local types = require("@self/types")
|
||||
local untracked = require("@self/untracked")
|
||||
|
||||
export type Atom<T = any> = types.Atom<T>
|
||||
export type Selector<T = any> = types.Selector<T>
|
||||
|
||||
local atom = core.atom
|
||||
local computed = core.computed
|
||||
local effect = core.effect
|
||||
local effectScope = core.effectScope
|
||||
local onCleanup = core.onCleanup
|
||||
|
||||
return {
|
||||
core = core,
|
||||
strict = strict,
|
||||
atom = atom,
|
||||
batched = batched,
|
||||
computed = computed,
|
||||
effect = effect,
|
||||
effectScope = effectScope,
|
||||
listen = listen,
|
||||
mapped = mapped,
|
||||
observe = observe,
|
||||
onCleanup = onCleanup,
|
||||
subscribe = subscribe,
|
||||
untracked = untracked,
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
local core = require("./core")
|
||||
local untracked = require("./untracked")
|
||||
|
||||
local computed = core.computed
|
||||
local effect = core.effect
|
||||
|
||||
local function listen<T>(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> ()
|
||||
local tracked = computed(function()
|
||||
return selector()
|
||||
end)
|
||||
|
||||
local current: T
|
||||
local disposed = false
|
||||
|
||||
local function dispose()
|
||||
disposed = true
|
||||
end
|
||||
|
||||
dispose = effect(function()
|
||||
local previous = current
|
||||
current = tracked()
|
||||
untracked(listener, current, previous, dispose)
|
||||
end)
|
||||
|
||||
if disposed then
|
||||
dispose()
|
||||
end
|
||||
|
||||
return dispose
|
||||
end
|
||||
|
||||
return listen
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
local core = require("./core")
|
||||
|
||||
local computed = core.computed
|
||||
local setCurrentSub = core.setCurrentSub
|
||||
|
||||
type Mapped =
|
||||
& (<KI, VI, KO, VO>(selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO })
|
||||
& (<K, VI, VO>(selector: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO })
|
||||
|
||||
local function mapped<KI, VI, KO, VO>(selector: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO }
|
||||
local currentData: { [KI]: VI } = {}
|
||||
local keyCache: { [KI]: KO } = {}
|
||||
|
||||
local function map(value: VI, key: KI): (VO, KO)
|
||||
local currentSub = setCurrentSub(nil)
|
||||
local success, valueOut, keyOut = pcall(mapper, value, key)
|
||||
setCurrentSub(currentSub)
|
||||
|
||||
if not success then
|
||||
error(`mapped() transform function failed: {valueOut}`)
|
||||
end
|
||||
|
||||
return valueOut, if keyOut == nil then key else keyOut :: any
|
||||
end
|
||||
|
||||
return computed(function(previousOutput)
|
||||
local currentOutput: { [KO]: VO } = previousOutput or {}
|
||||
local previousData = currentData
|
||||
currentData = selector()
|
||||
|
||||
for key in previousData do
|
||||
if currentData[key] ~= nil then
|
||||
continue
|
||||
end
|
||||
local cachedKey = keyCache[key]
|
||||
if cachedKey then
|
||||
currentOutput = if currentOutput == previousOutput then table.clone(currentOutput) else currentOutput
|
||||
currentOutput[cachedKey] = nil
|
||||
keyCache[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
for key, value in currentData do
|
||||
if value == previousData[key] then
|
||||
continue
|
||||
end
|
||||
local valueOut, keyOut = map(value, key)
|
||||
if currentOutput[keyOut] ~= valueOut then
|
||||
currentOutput = if currentOutput == previousOutput then table.clone(currentOutput) else currentOutput
|
||||
currentOutput[keyOut] = valueOut
|
||||
keyCache[key] = keyOut
|
||||
end
|
||||
end
|
||||
|
||||
return currentOutput
|
||||
end)
|
||||
end
|
||||
|
||||
return mapped :: Mapped
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
local core = require("./core")
|
||||
|
||||
local effect = core.effect
|
||||
local effectScope = core.effectScope
|
||||
local onCleanup = core.onCleanup
|
||||
|
||||
local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> ()
|
||||
local scopes: { [K]: () -> () } = {}
|
||||
local currentItems: { [K]: V } = {}
|
||||
|
||||
local function updateScopes(currentItems: { [K]: V }, previousItems: { [K]: V })
|
||||
for key in previousItems do
|
||||
local dispose = scopes[key]
|
||||
if currentItems[key] == nil and dispose then
|
||||
dispose()
|
||||
scopes[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
for key, value in currentItems do
|
||||
if previousItems[key] == nil then
|
||||
scopes[key] = effectScope(function()
|
||||
return observer(value, key)
|
||||
end, true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function disposeScopes()
|
||||
for _, dispose in scopes do
|
||||
dispose()
|
||||
end
|
||||
table.clear(scopes)
|
||||
end
|
||||
|
||||
local disposeEffect = effect(function()
|
||||
local previousItems = currentItems
|
||||
currentItems = selector()
|
||||
if currentItems ~= previousItems then
|
||||
updateScopes(currentItems, previousItems)
|
||||
end
|
||||
end)
|
||||
|
||||
onCleanup(disposeScopes, true)
|
||||
|
||||
return function()
|
||||
disposeEffect()
|
||||
disposeScopes()
|
||||
end
|
||||
end
|
||||
|
||||
return observe
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
local function inlineTest(): string
|
||||
return debug.info(1, "n")
|
||||
end
|
||||
|
||||
return {
|
||||
-- Enable strict mode in O2
|
||||
enabled = inlineTest() == "inlineTest",
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
local core = require("./core")
|
||||
local untracked = require("./untracked")
|
||||
|
||||
local computed = core.computed
|
||||
local effect = core.effect
|
||||
|
||||
local function subscribe<T>(selector: () -> T, listener: (state: T, lastState: T) -> ()): () -> ()
|
||||
local tracked = computed(function()
|
||||
return selector()
|
||||
end)
|
||||
|
||||
local current: T
|
||||
local mounted = true
|
||||
|
||||
return effect(function()
|
||||
local previous = current
|
||||
current = tracked()
|
||||
if mounted then
|
||||
mounted = false
|
||||
else
|
||||
untracked(listener, current, previous)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return subscribe
|
||||
|
|
@ -1,357 +0,0 @@
|
|||
--[[
|
||||
alien-signals 2.0.1 implementation adopted from
|
||||
https://github.com/Nicell/alien-signals-luau
|
||||
https://github.com/stackblitz/alien-signals
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-present Johnson Chu, Nicholas Winans
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
]]
|
||||
|
||||
export type ReactiveNode = {
|
||||
deps: Link?,
|
||||
depsTail: Link?,
|
||||
subs: Link?,
|
||||
subsTail: Link?,
|
||||
flags: ReactiveFlags,
|
||||
}
|
||||
|
||||
export type Link = {
|
||||
dep: ReactiveNode,
|
||||
sub: ReactiveNode,
|
||||
prevSub: Link?,
|
||||
nextSub: Link?,
|
||||
prevDep: Link?,
|
||||
nextDep: Link?,
|
||||
}
|
||||
|
||||
type Stack<T> = {
|
||||
value: T,
|
||||
prev: Stack<T>?,
|
||||
}
|
||||
|
||||
export type ReactiveFlags = number
|
||||
|
||||
local NONE = 0b0000000
|
||||
local MUTABLE = 0b0000001
|
||||
local WATCHING = 0b0000010
|
||||
local RECURSED_CHECK = 0b0000100
|
||||
local RECURSED = 0b0001000
|
||||
local DIRTY = 0b0010000
|
||||
local PENDING = 0b0100000
|
||||
|
||||
local ReactiveFlags = {
|
||||
None = NONE,
|
||||
Mutable = MUTABLE,
|
||||
Watching = WATCHING,
|
||||
RecursedCheck = RECURSED_CHECK,
|
||||
Recursed = RECURSED,
|
||||
Dirty = DIRTY,
|
||||
Pending = PENDING,
|
||||
}
|
||||
|
||||
local function createReactiveSystem(props: {
|
||||
update: (sub: ReactiveNode) -> boolean,
|
||||
notify: (sub: ReactiveNode) -> (),
|
||||
unwatched: (sub: ReactiveNode) -> (),
|
||||
})
|
||||
local update = props.update
|
||||
local notify = props.notify
|
||||
local unwatched = props.unwatched
|
||||
|
||||
local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
|
||||
local depsTail = sub.depsTail
|
||||
if depsTail then
|
||||
local link = sub.deps :: Link
|
||||
repeat
|
||||
if link == checkLink then
|
||||
return true
|
||||
end
|
||||
if link == depsTail then
|
||||
break
|
||||
end
|
||||
link = link.nextDep :: Link
|
||||
until not link
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function link(dep: ReactiveNode, sub: ReactiveNode): ()
|
||||
local prevDep = sub.depsTail
|
||||
if prevDep and prevDep.dep == dep then
|
||||
return
|
||||
end
|
||||
local nextDep: Link?
|
||||
local recursedCheck = bit32.btest(sub.flags, RECURSED_CHECK)
|
||||
if recursedCheck then
|
||||
nextDep = if prevDep then prevDep.nextDep else sub.deps
|
||||
if nextDep and nextDep.dep == dep then
|
||||
sub.depsTail = nextDep
|
||||
return
|
||||
end
|
||||
end
|
||||
local prevSub = dep.subsTail
|
||||
if prevSub and prevSub.sub == sub and (not recursedCheck or isValidLink(prevSub, sub)) then
|
||||
return
|
||||
end
|
||||
local newLink: Link = {
|
||||
dep = dep,
|
||||
sub = sub,
|
||||
prevDep = prevDep,
|
||||
nextDep = nextDep,
|
||||
prevSub = prevSub,
|
||||
}
|
||||
sub.depsTail = newLink
|
||||
dep.subsTail = newLink
|
||||
if nextDep then
|
||||
nextDep.prevDep = newLink
|
||||
end
|
||||
if prevDep then
|
||||
prevDep.nextDep = newLink
|
||||
else
|
||||
sub.deps = newLink
|
||||
end
|
||||
if prevSub then
|
||||
prevSub.nextSub = newLink
|
||||
else
|
||||
dep.subs = newLink
|
||||
end
|
||||
end
|
||||
|
||||
local function unlink(link: Link, sub: ReactiveNode?): Link?
|
||||
local sub = sub or link.sub
|
||||
local dep = link.dep
|
||||
local prevDep = link.prevDep
|
||||
local nextDep = link.nextDep
|
||||
local nextSub = link.nextSub
|
||||
local prevSub = link.prevSub
|
||||
if nextDep then
|
||||
nextDep.prevDep = prevDep
|
||||
else
|
||||
sub.depsTail = prevDep
|
||||
end
|
||||
if prevDep then
|
||||
prevDep.nextDep = nextDep
|
||||
else
|
||||
sub.deps = nextDep
|
||||
end
|
||||
if nextSub then
|
||||
nextSub.prevSub = prevSub
|
||||
else
|
||||
dep.subsTail = prevSub
|
||||
end
|
||||
if prevSub then
|
||||
prevSub.nextSub = nextSub
|
||||
else
|
||||
dep.subs = nextSub
|
||||
if not nextSub then
|
||||
unwatched(dep)
|
||||
end
|
||||
end
|
||||
return nextDep
|
||||
end
|
||||
|
||||
local function propagate(link: Link): ()
|
||||
local next = link.nextSub
|
||||
local stack: Stack<Link?>?
|
||||
|
||||
repeat
|
||||
local sub = link.sub
|
||||
|
||||
local flags = sub.flags
|
||||
|
||||
if bit32.btest(flags, bit32.bor(MUTABLE, WATCHING)) then
|
||||
if not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) then
|
||||
sub.flags = bit32.bor(flags, PENDING)
|
||||
elseif not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED)) then
|
||||
flags = NONE
|
||||
elseif not bit32.btest(flags, RECURSED_CHECK) then
|
||||
sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING)
|
||||
elseif not bit32.btest(flags, bit32.bor(DIRTY, PENDING)) and isValidLink(link, sub) then
|
||||
sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING))
|
||||
flags = bit32.band(flags, MUTABLE)
|
||||
else
|
||||
-- selene: allow(if_same_then_else)
|
||||
flags = NONE
|
||||
end
|
||||
|
||||
if bit32.btest(flags, WATCHING) then
|
||||
notify(sub)
|
||||
end
|
||||
|
||||
if bit32.btest(flags, MUTABLE) then
|
||||
local subSubs = sub.subs
|
||||
if subSubs then
|
||||
link = subSubs
|
||||
if subSubs.nextSub then
|
||||
stack = { value = next, prev = stack }
|
||||
next = link.nextSub
|
||||
end
|
||||
continue
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
link = next :: Link
|
||||
|
||||
if link then
|
||||
next = link.nextSub
|
||||
continue
|
||||
end
|
||||
|
||||
local continueTop = false
|
||||
|
||||
while stack do
|
||||
link = stack.value :: Link
|
||||
stack = stack.prev
|
||||
if link then
|
||||
next = link.nextSub
|
||||
continueTop = true
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if continueTop then
|
||||
continue
|
||||
end
|
||||
|
||||
break
|
||||
until false
|
||||
end
|
||||
|
||||
local function startTracking(sub: ReactiveNode): ()
|
||||
sub.depsTail = nil
|
||||
sub.flags = bit32.bor(bit32.band(sub.flags, bit32.bnot(bit32.bor(RECURSED, DIRTY, PENDING))), RECURSED_CHECK)
|
||||
end
|
||||
|
||||
local function endTracking(sub: ReactiveNode): ()
|
||||
local depsTail = sub.depsTail
|
||||
local toRemove = if depsTail then depsTail.nextDep else sub.deps
|
||||
while toRemove do
|
||||
toRemove = unlink(toRemove, sub)
|
||||
end
|
||||
sub.flags = bit32.band(sub.flags, bit32.bnot(RECURSED_CHECK))
|
||||
end
|
||||
|
||||
local function shallowPropagate(link: Link): ()
|
||||
repeat
|
||||
local sub = link.sub
|
||||
local subFlags = sub.flags
|
||||
if bit32.band(subFlags, bit32.bor(PENDING, DIRTY)) == PENDING then
|
||||
sub.flags = bit32.bor(subFlags, DIRTY)
|
||||
if bit32.btest(subFlags, WATCHING) then
|
||||
notify(sub)
|
||||
end
|
||||
end
|
||||
link = link.nextSub :: Link
|
||||
until not link
|
||||
end
|
||||
|
||||
local function checkDirty(link: Link, sub: ReactiveNode): boolean
|
||||
local stack: Stack<Link>?
|
||||
local checkDepth = 0
|
||||
|
||||
repeat
|
||||
local dep = link.dep
|
||||
local depFlags = dep.flags
|
||||
|
||||
local dirty = false
|
||||
|
||||
if bit32.btest(sub.flags, DIRTY) then
|
||||
dirty = true
|
||||
elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then
|
||||
if update(dep) then
|
||||
local subs = dep.subs :: Link
|
||||
if subs.nextSub then
|
||||
shallowPropagate(subs)
|
||||
end
|
||||
dirty = true
|
||||
end
|
||||
elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then
|
||||
if link.nextSub or link.prevSub then
|
||||
stack = { value = link, prev = stack }
|
||||
end
|
||||
link = dep.deps :: Link
|
||||
sub = dep
|
||||
checkDepth += 1
|
||||
continue
|
||||
end
|
||||
|
||||
if not dirty and link.nextDep then
|
||||
link = link.nextDep
|
||||
continue
|
||||
end
|
||||
|
||||
local continueTop = false
|
||||
|
||||
while checkDepth ~= 0 do
|
||||
checkDepth -= 1
|
||||
local firstSub = sub.subs :: Link
|
||||
local hasMultipleSubs = firstSub.nextSub ~= nil
|
||||
if hasMultipleSubs then
|
||||
link = (stack :: Stack<Link>).value
|
||||
stack = (stack :: Stack<Link>).prev
|
||||
else
|
||||
link = firstSub
|
||||
end
|
||||
if dirty then
|
||||
if update(sub) then
|
||||
if hasMultipleSubs then
|
||||
shallowPropagate(firstSub)
|
||||
end
|
||||
sub = link.sub
|
||||
continue
|
||||
end
|
||||
else
|
||||
sub.flags = bit32.band(sub.flags, bit32.bnot(PENDING))
|
||||
end
|
||||
sub = link.sub
|
||||
if link.nextDep then
|
||||
link = link.nextDep
|
||||
continueTop = true
|
||||
break
|
||||
end
|
||||
dirty = false
|
||||
end
|
||||
|
||||
if continueTop then
|
||||
continue
|
||||
end
|
||||
|
||||
return dirty
|
||||
until false
|
||||
end
|
||||
|
||||
return {
|
||||
link = link,
|
||||
unlink = unlink,
|
||||
propagate = propagate,
|
||||
checkDirty = checkDirty,
|
||||
endTracking = endTracking,
|
||||
startTracking = startTracking,
|
||||
shallowPropagate = shallowPropagate,
|
||||
}
|
||||
end
|
||||
|
||||
return {
|
||||
ReactiveFlags = ReactiveFlags,
|
||||
createReactiveSystem = createReactiveSystem,
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
type Update<T> = ((previous: T) -> T) | T
|
||||
export type Atom<T> = (...Update<T>) -> T
|
||||
export type Selector<T> = () -> T
|
||||
|
||||
return {}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
local core = require("./core")
|
||||
|
||||
local setCurrentTracking = core.setCurrentTracking
|
||||
|
||||
local function handleUntracked(prevTracking: boolean, ok: boolean, ...)
|
||||
setCurrentTracking(prevTracking)
|
||||
if ok then
|
||||
return ...
|
||||
else
|
||||
error((...))
|
||||
end
|
||||
end
|
||||
|
||||
local function untracked<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
|
||||
return handleUntracked(setCurrentTracking(false), pcall(callback, ...))
|
||||
end
|
||||
|
||||
return untracked
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
local function handleNoYield(thread: thread, ok: boolean, ...)
|
||||
if not ok then
|
||||
return false, debug.traceback(thread, tostring((...)))
|
||||
elseif coroutine.status(thread) ~= "dead" then
|
||||
return false, debug.traceback(thread, "Attempted to yield!")
|
||||
else
|
||||
return true, ...
|
||||
end
|
||||
end
|
||||
|
||||
local function noYield<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): (boolean, Results...)
|
||||
local thread = coroutine.create(callback)
|
||||
return handleNoYield(thread, coroutine.resume(thread, ...))
|
||||
end
|
||||
|
||||
return {
|
||||
noYield = noYield :: typeof(pcall),
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
[package]
|
||||
name = "littensy/charm"
|
||||
version = "0.10.0"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = ["node_modules", "src/__tests__", "src/index.d.ts", "package.json"]
|
||||
|
||||
[dependencies]
|
||||
|
|
@ -13,11 +13,11 @@ local function renderHook<Props, Result>(callback: (Props) -> Result, props: Pro
|
|||
local root = ReactRoblox.createLegacyRoot(Instance.new("Folder"))
|
||||
local hook = {}
|
||||
|
||||
local function Component(props)
|
||||
local function Component(props: Props)
|
||||
hook.result = callback(props)
|
||||
end
|
||||
|
||||
function hook.rerender(newProps)
|
||||
function hook.rerender(newProps: Props?)
|
||||
props = newProps or props
|
||||
ReactRoblox.act(function()
|
||||
root:render(React.createElement(Component, props))
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
local suite = require("../../../../test/suite")
|
||||
|
||||
suite.describe("react-charm", function()
|
||||
if not game then
|
||||
return suite.skip()
|
||||
end
|
||||
suite.describe("useAtom", function()
|
||||
require("./useAtom.spec")
|
||||
end)
|
||||
suite.describe("useAtomBinding", function()
|
||||
require("./useAtomBinding.spec")
|
||||
end)
|
||||
suite.describe("useAtom", function()
|
||||
require("./useAtom.spec")
|
||||
end)
|
||||
|
||||
suite.describe("useAtomBinding", function()
|
||||
require("./useAtomBinding.spec")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
30
packages/react-charm/src/index.d.ts
vendored
30
packages/react-charm/src/index.d.ts
vendored
|
|
@ -1 +1,29 @@
|
|||
export {};
|
||||
import { Binding } from "@rbxts/react";
|
||||
|
||||
/**
|
||||
* A hook that subscribes to changes in the given atom or selector. The
|
||||
* component is re-rendered with the new state after an update.
|
||||
*
|
||||
* If the `dependencies` array is provided, a new effect is created for the
|
||||
* current `callback` when the dependencies change. Otherwise, the effect is
|
||||
* created only once, when the component is mounted.
|
||||
*
|
||||
* @param callback The atom or selector to watch.
|
||||
* @param dependencies An array of values used to memoize the callback.
|
||||
* @returns The current state.
|
||||
*/
|
||||
export function useAtom<T>(callback: () => T, dependencies?: any[]): T;
|
||||
|
||||
/**
|
||||
* A hook that subscribes to changes in the given atom or selector. Returns
|
||||
* a binding that updates when the state changes.
|
||||
*
|
||||
* If the `dependencies` array is provided, a new effect is created for the
|
||||
* current `callback` when the dependencies change. Otherwise, the effect is
|
||||
* created only once, when the component is mounted.
|
||||
*
|
||||
* @param callback The atom or selector to watch.
|
||||
* @param dependencies An array of values used to memoize the callback.
|
||||
* @returns A binding that stores the current state.
|
||||
*/
|
||||
export function useAtomBinding<T>(callback: () => T, dependencies?: any[]): Binding<T>;
|
||||
|
|
|
|||
|
|
@ -8,21 +8,21 @@ local useState = React.useState
|
|||
local useMemo = React.useMemo
|
||||
|
||||
--- A hook that subscribes to changes in the given atom or selector. The
|
||||
--- component is re-rendered whenever the state changes.
|
||||
--- component is re-rendered with the new state after an update.
|
||||
---
|
||||
--- If the `dependencies` array is provided, the effect is re-created whenever
|
||||
--- the dependencies change. Otherwise, the subscription is created once when
|
||||
--- the component is mounted.
|
||||
--- If the `dependencies` array is provided, a new effect is created for the
|
||||
--- current `callback` when the dependencies change. Otherwise, the effect is
|
||||
--- created only once, when the component is mounted.
|
||||
---
|
||||
--- @param selector The atom or selector to subscribe to.
|
||||
--- @param dependencies An array of values that the subscription depends on.
|
||||
--- @param callback The atom or selector to watch.
|
||||
--- @param dependencies An array of values used to memoize the callback.
|
||||
--- @return The current state.
|
||||
local function useAtom<State>(selector: () -> State, dependencies: { any }?): State
|
||||
local state, setState = useState(selector)
|
||||
local function useAtom<T>(callback: () -> T, dependencies: { any }?): T
|
||||
local state, setState = useState(callback)
|
||||
|
||||
useEffect(function()
|
||||
return effect(function()
|
||||
setState(selector())
|
||||
setState(callback())
|
||||
end)
|
||||
end, dependencies or {})
|
||||
|
||||
|
|
@ -32,20 +32,20 @@ end
|
|||
--- A hook that subscribes to changes in the given atom or selector. Returns
|
||||
--- a binding that updates when the state changes.
|
||||
---
|
||||
--- If the `dependencies` array is provided, the effect is re-created whenever
|
||||
--- the dependencies change. Otherwise, the subscription is created once when
|
||||
--- the component is mounted.
|
||||
--- If the `dependencies` array is provided, a new effect is created for the
|
||||
--- current `callback` when the dependencies change. Otherwise, the effect is
|
||||
--- created only once, when the component is mounted.
|
||||
---
|
||||
--- @param selector The atom or selector to subscribe to.
|
||||
--- @param dependencies An array of values that the subscription depends on.
|
||||
--- @param callback The atom or selector to watch.
|
||||
--- @param dependencies An array of values used to memoize the callback.
|
||||
--- @return A binding that stores the current state.
|
||||
local function useAtomBinding<State>(selector: () -> State, dependencies: { any }?)
|
||||
local initialState = useMemo(selector, {})
|
||||
local function useAtomBinding<T>(callback: () -> T, dependencies: { any }?)
|
||||
local initialState = useMemo(callback, {})
|
||||
local binding, setBinding = useBinding(initialState)
|
||||
|
||||
useEffect(function()
|
||||
return effect(function()
|
||||
setBinding(selector())
|
||||
setBinding(callback())
|
||||
end)
|
||||
end, dependencies or {})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,7 @@
|
|||
local suite = require("../../../../test/suite")
|
||||
|
||||
suite.describe("vide-charm", function()
|
||||
if not game then
|
||||
return suite.skip()
|
||||
end
|
||||
suite.describe("useAtom", function()
|
||||
require("./useAtom.spec")
|
||||
end)
|
||||
suite.describe("useAtom", function()
|
||||
require("./useAtom.spec")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
9
packages/vide-charm/src/index.d.ts
vendored
9
packages/vide-charm/src/index.d.ts
vendored
|
|
@ -1 +1,8 @@
|
|||
export {};
|
||||
/**
|
||||
* Subscribes to the state of an atom or selector. Returns a source that
|
||||
* updates when the state changes.
|
||||
*
|
||||
* @param callback The atom or selector to subscribe to.
|
||||
* @returns The reactive source.
|
||||
*/
|
||||
export function useAtom<T>(callback: () => T, dependencies?: any[]): () => T;
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ local cleanup = Vide.cleanup
|
|||
--- Subscribes to the state of an atom or selector. Returns a source that
|
||||
--- updates when the state changes.
|
||||
---
|
||||
--- @param selector The atom or selector to subscribe to.
|
||||
--- @param callback The atom or selector to subscribe to.
|
||||
--- @return The reactive source.
|
||||
local function useAtom<State>(selector: () -> State): () -> State
|
||||
local state = source(selector())
|
||||
cleanup(subscribe(selector, state))
|
||||
local function useAtom<State>(callback: () -> State): () -> State
|
||||
local state = source(callback())
|
||||
cleanup(subscribe(callback, state))
|
||||
return state
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue