Signal - a more advanced version of native Roblox signals

**Current Version: 1.0.0**

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;
3 Likes