Skip to content
This repository has been archived by the owner on Dec 13, 2023. It is now read-only.

LUAFDN-268 optimize createSignal memory usage #304

Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased Changes
* Added color schemes for documentation based on user preference ([#290](https://github.com/Roblox/roact/pull/290)).
* Fixed stack trace level when throwing an error in `createReconciler` ([#297](https://github.com/Roblox/roact/pull/297)).
* Optimized the memory usage of 'createSignal' implementation. ([#304](https://github.com/Roblox/roact/pull/304))

## [1.3.1](https://github.com/Roblox/roact/releases/tag/v1.3.0) (November 19th, 2020)
* Added component name to property validation error message ([#275](https://github.com/Roblox/roact/pull/275))
Expand Down
49 changes: 21 additions & 28 deletions src/createSignal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -12,58 +12,51 @@
disconnect()
]]

local function addToMap(map, addKey, addValue)
local new = {}

for key, value in pairs(map) do
new[key] = value
end

new[addKey] = addValue

return new
end

local function removeFromMap(map, removeKey)
local new = {}

for key, value in pairs(map) do
if key ~= removeKey then
new[key] = value
end
end

return new
end

local function createSignal()
local connections = {}
local suspendedConnections = {}
local firing = false

local function subscribe(self, callback)
assert(typeof(callback) == "function", "Can only subscribe to signals with a function.")

local connection = {
callback = callback,
disconnected = false,
}

connections = addToMap(connections, callback, connection)
-- If the callback is already registered, don't add to the suspendedConnection. Otherwise, this will disable
-- the existing one.
if firing and not connections[callback] then
suspendedConnections[callback] = connection
end

connections[callback] = connection

local function disconnect()
assert(not connection.disconnected, "Listeners can only be disconnected once.")

connection.disconnected = true
connections = removeFromMap(connections, callback)
connections[callback] = nil
suspendedConnections[callback] = nil
end

return disconnect
end

local function fire(self, ...)
firing = true
for callback, connection in pairs(connections) do
if not connection.disconnected then
if not connection.disconnected and not suspendedConnections[callback] then
callback(...)
end
end

firing = false

for callback, _ in pairs(suspendedConnections) do
suspendedConnections[callback] = nil
end
end

return {
Expand All @@ -72,4 +65,4 @@ local function createSignal()
}
end

return createSignal
return createSignal
65 changes: 65 additions & 0 deletions src/createSignal.spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,69 @@ return function()
-- Exactly once listener should have been called.
expect(spyA.callCount + spyB.callCount).to.equal(1)
end)

it("should allow adding listener in the middle of firing", function()
local signal = createSignal()

local disconnectA
local spyA = createSpy()
local listener = function(a, b)
disconnectA = signal:subscribe(spyA.value)
end

local disconnectListener = signal:subscribe(listener)

expect(spyA.callCount).to.equal(0)

local a = {}
local b = 67
signal:fire(a, b)

expect(spyA.callCount).to.equal(0)

-- The new listener should be picked up in next fire.
signal:fire(b, a)
expect(spyA.callCount).to.equal(1)
spyA:assertCalledWith(b, a)

disconnectA()
disconnectListener()

signal:fire(a)

expect(spyA.callCount).to.equal(1)
end)

it("should have one connection instance when add the same listener multiple times", function()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we have it warn the user when adding the same one multiple times? when this happens, it's probably the symptom of a deeper bug, right?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what it's worth, we try to be cautious about warnings when Roact powers things like in-game menu and studio plugins. React doesn't actually expose its signal implementation, so warning about this would signal an internal bug. If this cropped up in shipped code, we'd end up confusingly printing warnings to developers in studio about things that have nothing to do with them!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a warning message.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I'm a bit worried about the warning too, I am not sure if that can bubble up to the consumers of Roact. I've checked out createContext and Binding and it seems ok to me

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will remove it for now. We can loop back on the warning message in a separate PR if necessary

local signal = createSignal()

local spyA = createSpy()
local disconnect1 = signal:subscribe(spyA.value)

expect(spyA.callCount).to.equal(0)

local a = {}
local b = 67
signal:fire(a, b)

expect(spyA.callCount).to.equal(1)
spyA:assertCalledWith(a, b)

local disconnect2 = signal:subscribe(spyA.value)

signal:fire(b, a)
expect(spyA.callCount).to.equal(2)
spyA:assertCalledWith(b, a)

disconnect2()

signal:fire(a)

expect(spyA.callCount).to.equal(2)

-- should have no effect.
disconnect1()
signal:fire(a)
expect(spyA.callCount).to.equal(2)
end)
end