Skip to content

Commit d0c21a7

Browse files
Better error reporter interface (#61)
We were a bit hasty with the changes that were made to v2.0.0! It seems prudent to circle back and make a few improvements: Reverts the default behavior to throwing the error instead of printing it (this aligns better with expectations from Rodux 1.x) Don't inherit the deferred and immediate abstraction from external codebases. Instead, categorize errors into ones that occur when processing actions with reportReducerError (running reducers on dispatched actions) and ones that occur when updating listeners with reportUpdateError (flushing new state) For the error reporter interface, provide a set of data relevant to each situation. This includes recent actions, store state, and the thrown error Removes error reporting from signal, to try to keep it simple and generic; most usage of rodux is through helper libraries that manage the signal connections, anyway. Updates tests to account for new expected behavior Carves out some overzealous printing logic; users of the error reporter API can opt into it isntead
1 parent c9b9807 commit d0c21a7

File tree

9 files changed

+331
-391
lines changed

9 files changed

+331
-391
lines changed

src/Signal.lua

Lines changed: 10 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
Handlers are fired in order, and (dis)connections are properly handled when
55
executing an event.
66
]]
7-
local inspect = require(script.Parent.inspect).inspect
8-
97
local function immutableAppend(list, ...)
108
local new = {}
119
local len = #list
@@ -56,8 +54,8 @@ function Signal:connect(callback)
5654
if self._store and self._store._isDispatching then
5755
error(
5856
'You may not call store.changed:connect() while the reducer is executing. ' ..
59-
'If you would like to be notified after the store has been updated, subscribe from a ' ..
60-
'component and invoke store:getState() in the callback to access the latest state. '
57+
'If you would like to be notified after the store has been updated, subscribe from a ' ..
58+
'component and invoke store:getState() in the callback to access the latest state. '
6159
)
6260
end
6361

@@ -72,14 +70,13 @@ function Signal:connect(callback)
7270

7371
local function disconnect()
7472
if listener.disconnected then
75-
local errorMessage = ("Listener connected at: \n%s\n" ..
76-
"was already disconnected at: \n%s\n"):format(
77-
tostring(listener.connectTraceback),
78-
tostring(listener.disconnectTraceback)
79-
)
80-
self._store._errorReporter:reportErrorDeferred(errorMessage, debug.traceback())
81-
82-
return
73+
error((
74+
"Listener connected at: \n%s\n" ..
75+
"was already disconnected at: \n%s\n"
76+
):format(
77+
tostring(listener.connectTraceback),
78+
tostring(listener.disconnectTraceback)
79+
))
8380
end
8481

8582
if self._store and self._store._isDispatching then
@@ -96,35 +93,10 @@ function Signal:connect(callback)
9693
}
9794
end
9895

99-
function Signal:reportListenerError(listener, callbackArgs, error_)
100-
local message = ("Caught error when calling event listener (%s), " ..
101-
"originally subscribed from: \n%s\n" ..
102-
"with arguments: \n%s\n"):format(
103-
tostring(listener.callback),
104-
tostring(listener.connectTraceback),
105-
inspect(callbackArgs)
106-
)
107-
108-
if self._store then
109-
self._store._errorReporter:reportErrorImmediately(message, error_)
110-
else
111-
print(message .. tostring(error_))
112-
end
113-
end
114-
11596
function Signal:fire(...)
11697
for _, listener in ipairs(self._listeners) do
11798
if not listener.disconnected then
118-
local ok, result = pcall(function(...)
119-
listener.callback(...)
120-
end, ...)
121-
if not ok then
122-
self:reportListenerError(
123-
listener,
124-
{...},
125-
result
126-
)
127-
end
99+
listener.callback(...)
128100
end
129101
end
130102
end

src/Signal.spec.lua

Lines changed: 39 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -112,84 +112,55 @@ return function()
112112
expect(countB).to.equal(0)
113113
end)
114114

115-
describe("when event handlers error", function()
116-
local reportedErrorError, reportedErrorMessage
117-
local mockStore = {
118-
_errorReporter = {
119-
reportErrorImmediately = function(_self, message, error_)
120-
reportedErrorMessage = message
121-
reportedErrorError = error_
122-
end,
123-
reportErrorDeferred = function(_self, message, error_)
124-
reportedErrorMessage = message
125-
reportedErrorError = error_
126-
end
127-
}
128-
}
129-
130-
beforeEach(function()
131-
reportedErrorError = ""
132-
reportedErrorMessage = ""
133-
end)
115+
it("should throw an error if the argument to `connect` is not a function", function()
116+
local signal = Signal.new()
117+
expect(function()
118+
signal:connect("not a function")
119+
end).to.throw()
120+
end)
134121

135-
it("first listener succeeds when second listener errors", function()
136-
local signal = Signal.new(mockStore)
137-
local countA = 0
122+
it("should throw an error when disconnecting more than once", function()
123+
local signal = Signal.new()
138124

139-
signal:connect(function()
140-
countA = countA + 1
141-
end)
125+
local connection = signal:connect(function() end)
126+
-- Okay to disconnect once
127+
expect(connection.disconnect).never.to.throw()
142128

143-
signal:connect(function()
144-
error("connectionB")
145-
end)
129+
-- Throw an error if we disconnect twice
130+
expect(connection.disconnect).to.throw()
131+
end)
146132

147-
signal:fire()
133+
it("should throw an error when subscribing during dispatch", function()
134+
local mockStore = {
135+
_isDispatching = false
136+
}
137+
local signal = Signal.new(mockStore)
148138

149-
expect(countA).to.equal(1)
150-
local caughtErrorMessage = "Caught error when calling event listener"
151-
expect(string.find(reportedErrorMessage, caughtErrorMessage)).to.be.ok()
152-
local caughtErrorError = "connectionB"
153-
expect(string.find(reportedErrorError, caughtErrorError)).to.be.ok()
139+
signal:connect(function()
140+
-- Subscribe while listeners are being fired
141+
signal:connect(function() end)
154142
end)
155143

156-
it("second listener succeeds when first listener errors", function()
157-
local signal = Signal.new(mockStore)
158-
local countB = 0
159-
160-
signal:connect(function()
161-
error("connectionA")
162-
end)
163-
164-
signal:connect(function()
165-
countB = countB + 1
166-
end)
167-
144+
mockStore._isDispatching = true
145+
expect(function()
168146
signal:fire()
147+
end).to.throw()
148+
end)
169149

170-
expect(countB).to.equal(1)
171-
local caughtErrorMessage = "Caught error when calling event listener"
172-
expect(string.find(reportedErrorMessage, caughtErrorMessage)).to.be.ok()
173-
local caughtErrorError = "connectionA"
174-
expect(string.find(reportedErrorError, caughtErrorError)).to.be.ok()
175-
end)
176-
177-
it("serializes table arguments when reporting errors", function()
178-
local signal = Signal.new(mockStore)
179-
180-
signal:connect(function()
181-
error("connectionA")
182-
end)
183-
184-
local actionCommand = "SENTINEL"
185-
signal:fire({actionCommand = actionCommand})
150+
it("should throw an error when unsubscribing during dispatch", function()
151+
local mockStore = {
152+
_isDispatching = false
153+
}
154+
local signal = Signal.new(mockStore)
186155

187-
local caughtErrorMessage = "Caught error when calling event listener"
188-
local caughtErrorArg = "actionCommand: \"" .. actionCommand .. "\""
189-
expect(string.find(reportedErrorMessage, caughtErrorMessage)).to.be.ok()
190-
expect(string.find(reportedErrorMessage, caughtErrorArg)).to.be.ok()
191-
local caughtErrorError = "connectionA"
192-
expect(string.find(reportedErrorError, caughtErrorError)).to.be.ok()
156+
local connection
157+
connection = signal:connect(function()
158+
connection.disconnect()
193159
end)
160+
161+
mockStore._isDispatching = true
162+
expect(function()
163+
signal:fire()
164+
end).to.throw()
194165
end)
195166
end

src/Store.lua

Lines changed: 51 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,22 @@ local RunService = game:GetService("RunService")
22

33
local Signal = require(script.Parent.Signal)
44
local NoYield = require(script.Parent.NoYield)
5-
local inspect = require(script.Parent.inspect).inspect
65

7-
local defaultErrorReporter = {
8-
reportErrorDeferred = function(self, message, stacktrace)
9-
print(message)
10-
print(stacktrace)
6+
local ACTION_LOG_LENGTH = 3
7+
8+
local rethrowErrorReporter = {
9+
reportReducerError = function(prevState, action, errorResult)
10+
error(string.format("Received error: %s\n\n%s", errorResult.message, errorResult.thrownValue))
11+
end,
12+
reportUpdateError = function(prevState, currentState, lastActions, errorResult)
13+
error(string.format("Received error: %s\n\n%s", errorResult.message, errorResult.thrownValue))
1114
end,
12-
reportErrorImmediately = function(self, message, stacktrace)
13-
print(message)
14-
print(stacktrace)
15-
end
1615
}
1716

17+
local function tracebackReporter(message)
18+
return debug.traceback(tostring(message))
19+
end
20+
1821
local Store = {}
1922

2023
-- This value is exposed as a private value so that the test code can stay in
@@ -49,19 +52,21 @@ function Store.new(reducer, initialState, middlewares, errorReporter)
4952

5053
local self = {}
5154

52-
self._errorReporter = errorReporter or defaultErrorReporter
55+
self._errorReporter = errorReporter or rethrowErrorReporter
5356
self._isDispatching = false
5457
self._reducer = reducer
5558
local initAction = {
5659
type = "@@INIT",
5760
}
58-
self._lastAction = initAction
59-
local ok, result = pcall(function()
61+
self._actionLog = { initAction }
62+
local ok, result = xpcall(function()
6063
self._state = reducer(initialState, initAction)
61-
end)
64+
end, tracebackReporter)
6265
if not ok then
63-
local message = ("Caught error with init action of reducer (%s): %s"):format(tostring(reducer), tostring(result))
64-
errorReporter:reportErrorImmediately(message, debug.traceback())
66+
self._errorReporter.reportReducerError(initialState, initAction, {
67+
message = "Caught error in reducer with init",
68+
thrownValue = result,
69+
})
6570
self._state = initialState
6671
end
6772
self._lastState = self._state
@@ -110,20 +115,6 @@ function Store:getState()
110115
return self._state
111116
end
112117

113-
function Store:_reportReducerError(failedAction, error_, traceback)
114-
local message = ("Caught error when running action (%s) " ..
115-
"through reducer (%s): \n%s \n" ..
116-
"previous action type was: %s"
117-
):format(
118-
tostring(failedAction),
119-
tostring(self._reducer),
120-
tostring(error_),
121-
inspect(self._lastAction)
122-
)
123-
124-
self._errorReporter:reportErrorImmediately(message, traceback)
125-
end
126-
127118
--[[
128119
Dispatch an action to the store. This allows the store's reducer to mutate
129120
the state of the application by creating a new copy of the state.
@@ -142,7 +133,7 @@ function Store:dispatch(action)
142133
if action.type == nil then
143134
error("Actions may not have an undefined 'type' property. " ..
144135
"Have you misspelled a constant? \n" ..
145-
inspect(action), 2)
136+
tostring(action), 2)
146137
end
147138

148139
if self._isDispatching then
@@ -158,13 +149,20 @@ function Store:dispatch(action)
158149
self._isDispatching = false
159150

160151
if not ok then
161-
self:_reportReducerError(
152+
self._errorReporter.reportReducerError(
153+
self._state,
162154
action,
163-
result,
164-
debug.traceback()
155+
{
156+
message = "Caught error in reducer",
157+
thrownValue = result,
158+
}
165159
)
166160
end
167-
self._lastAction = action
161+
162+
if #self._actionLog == ACTION_LOG_LENGTH then
163+
table.remove(self._actionLog, 1)
164+
end
165+
table.insert(self._actionLog, action)
168166
end
169167

170168
--[[
@@ -193,11 +191,25 @@ function Store:flush()
193191
-- unless we cache this value first
194192
local state = self._state
195193

196-
-- If a changed listener yields, *very* surprising bugs can ensue.
197-
-- Because of that, changed listeners cannot yield.
198-
NoYield(function()
199-
self.changed:fire(state, self._lastState)
200-
end)
194+
local ok, errorResult = xpcall(function()
195+
-- If a changed listener yields, *very* surprising bugs can ensue.
196+
-- Because of that, changed listeners cannot yield.
197+
NoYield(function()
198+
self.changed:fire(state, self._lastState)
199+
end)
200+
end, tracebackReporter)
201+
202+
if not ok then
203+
self._errorReporter.reportUpdateError(
204+
self._lastState,
205+
state,
206+
self._actionLog,
207+
{
208+
message = "Caught error flushing store updates",
209+
thrownValue = errorResult,
210+
}
211+
)
212+
end
201213

202214
self._lastState = state
203215
end

0 commit comments

Comments
 (0)