Licensed under the MIT license.
While making my own UI framework, I made a signals module along with it. Inspired by Angular Signals, it takes in more advanced features and ease of use features that just work as intended.
Docs
Signal.Signal(Any initialValue)
Creates a new signal. To set or access the signal’s value, simply set/access the .value
property.
Signal.Effect(Function callback)
After the initial run, the callback function will run again when any signals accessed in the callback function change.
Signal.Computed(Function callback)
Creates a computed value, a value that can be based off of other Signals. A computed’s value will update when any of its dependencies update. Acts as a normal function, access .value
to see the signal’s value.
Warning: Don’t manually change a Computed value. This will cause many bugs.
Improvements to be made by version 2.0.0:
- Performance improvements
Source code
--!strict
--[[
Copyright 2024 shunnedreality. This code is licensed under the MIT license.
]]
local devEnvironment = _G.__DEV__;
export type Signal<T> = {
_value: T,
value: T,
dirty: boolean,
consumers: { Signal<any> },
dependencies: {
[Signal<any>]: {
version: number
}
},
version: number,
onMarkedDirty: ((
Signal<T>
) -> ())?
}
export type SignalEffect<T> = Signal<T> & {
compution: () -> T
}
local inNotificationPhase: boolean = false;
local notificationPhaseQueue: { () -> () } = {};
local activeSignal: Signal<any>? = nil;
function clearNotificationPhaseQueue()
for _, callback in notificationPhaseQueue do
callback();
end
table.clear(notificationPhaseQueue);
end
function isSignalVersionOld(
signal: Signal<any>,
dependency: Signal<any>
)
local storedVersion = signal.dependencies[dependency];
if storedVersion then
return dependency.version > storedVersion.version;
else
print(signal, dependency)
error("Something went wrong. There is no stored version of a dependency. \n" ..
"This is most likely a bug in signals.");
return true;
end
end
function isSignalADependency(
signal: Signal<any>,
dependency: Signal<any>
)
if signal.dependencies[dependency] then
return true;
else
return false;
end
end
function shouldMarkSignalDirty(
signal: Signal<any>,
dependency: Signal<any>
): boolean
local isSignalADependency = isSignalADependency(signal, dependency);
if not isSignalADependency then
local tableIndex = table.find(dependency.consumers, signal);
table.remove(dependency.consumers, tableIndex);
return false;
end;
local isSignalVersionOld = isSignalVersionOld(signal, dependency);
if isSignalVersionOld and isSignalADependency then
return true;
end
return false;
end
function notifySignalConsumers(
signal: Signal<any>
)
local previousNotificationPhase = inNotificationPhase;
inNotificationPhase = true;
for _, consumer in signal.consumers do
if shouldMarkSignalDirty(consumer, signal) then
markSignalDirty(consumer);
end
end
inNotificationPhase = previousNotificationPhase;
if not inNotificationPhase then
clearNotificationPhaseQueue();
end
end
function markSignalDirty(
signal: Signal<any>
)
signal.dirty = true;
signal.version += 1;
notifySignalConsumers(signal);
if signal.onMarkedDirty then
signal.onMarkedDirty(signal);
end
end
function waitForNotificationPhasePass(
callback: () -> ()
)
if not inNotificationPhase then
callback()
else
table.insert(notificationPhaseQueue, callback);
end
end
function onSignalAccess(
signal: Signal<any>
)
if inNotificationPhase then
if devEnvironment then
error("Attempt to access signal during notification phase.")
end
return;
end
if not activeSignal then
if devEnvironment then
warn("There isn't a current active signal. \n" ..
"This means that there's nothing to record during an update.")
end;
return;
end
table.insert(signal.consumers, activeSignal);
activeSignal.dependencies[signal] = {
version = signal.version
};
end
function areValuesSimilar(
a: unknown,
b: unknown
)
local typeA = typeof(a)
local isTable = typeA == "table"
local isUserdata = typeA == "userdata"
return
if not (isTable or isUserdata) then
a == b or a ~= a and b ~= b
elseif typeA == typeof(b) and (isUserdata or table.isfrozen(a :: any) or getmetatable(a :: any) ~= nil) then
a == b
else
false
end
function computeEffect(
effect: SignalEffect<any>,
callback: () -> any
)
if not effect.dirty then
if devEnvironment then
warn("Effect recompiled without being dirty.")
end;
return;
end
local previousValue = effect._value;
local previousDependencies = effect.dependencies;
effect.dependencies = {};
activeSignal = effect;
local computionStatus, computionResult = pcall(callback);
if computionStatus then
if not areValuesSimilar(previousValue, computionResult) then
effect.value = computionResult;
end
else
effect.dependencies = previousDependencies;
local stackTrace = debug.traceback();
coroutine.wrap(function()
error(computionResult .. "\n\n" .. stackTrace);
end)()
end
activeSignal = nil;
end
function signalMetatable<T>()
return {
__index = function(
self: Signal<T>,
index: string
)
if index == "value" then
onSignalAccess(self);
return rawget(self, "_value");
end
return rawget(self, index);
end,
__newindex = function(
self: Signal<T>,
index: string,
value: any
)
local previousValue = rawget(self, value);
if index == "dirty" and value == false and devEnvironment and inNotificationPhase then
warn("Don't make a signal undirty until after the notification phase.")
end
if index == "value" and not areValuesSimilar(previousValue, value) then
rawset(self, "_value", value);
markSignalDirty(self);
else
rawset(self, index, string)
end
end,
}
end
local function Signal<T>(
initialValue: T
): Signal<T>
return (setmetatable({
_value = initialValue,
dirty = false,
consumers = {},
version = 0,
dependencies = {},
onMarkedDirty = function(
self
)
self.dirty = false;
end,
}, signalMetatable()) :: any) :: Signal<T>
end
local function Effect(
callback: () -> ()
)
local watchSignal: Signal<"WATCH_SIGNAL"> = {
_value = "WATCH_SIGNAL",
value = "WATCH_SIGNAL",
dirty = false,
consumers = {},
dependencies = {},
version = 0,
onMarkedDirty = function(
watchSignal
)
activeSignal = watchSignal;
callback();
watchSignal.dirty = false;
activeSignal = nil;
end,
}
markSignalDirty(watchSignal);
end
local function Computed<T>(
callback: () -> T
): SignalEffect<T>
local computedSignal = (setmetatable({
_value = nil,
dirty = false,
consumers = {},
version = 0,
dependencies = {},
onMarkedDirty = function(
self
)
waitForNotificationPhasePass(function()
computeEffect(self, callback);
self.dirty = false;
end)
end,
}, signalMetatable()) :: any) :: SignalEffect<T>;
markSignalDirty(computedSignal);
return computedSignal;
end
local SignalExports = {};
SignalExports.Signal = Signal;
SignalExports.Effect = Effect;
SignalExports.Computed = Computed;
return SignalExports;