Skip to content

Commit b097043

Browse files
authored
Recover from bad inputs (#60)
Store and Signal will now catch errors that are thrown when reducers are firing and will prevent the Store from falling apart entirely when errors occur. Store.new now takes an optional error reporter interface, and defaults to printing. This is a breaking change, as the previous behavior was to propagate the error rather than print it.
1 parent b8ca02a commit b097043

17 files changed

+584
-60
lines changed

.luacheckrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ stds.roblox = {
2424
stds.testez = {
2525
read_globals = {
2626
"describe",
27+
"beforeEach", "afterEach", "beforeAll", "afterAll",
2728
"it", "itFOCUS", "itSKIP",
2829
"FOCUS", "SKIP", "HACK_NO_XPCALL",
2930
"expect",

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Rodux Changelog
22

33
## Unreleased Changes
4+
* Introduce error handling to catch and report errors during reducers ([#60](https://github.com/Roblox/rodux/pull/60)).
45

56
## 1.1.0 (2021-01-04)
67
* Added color schemes for documentation based on user preference ([#56](https://github.com/Roblox/rodux/pull/56)).

modules/lemur

Submodule lemur updated 154 files

modules/testez

Submodule testez updated 111 files

spec.lua

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
-- If you add any dependencies, add them to this table so they'll be loaded!
66
local LOAD_MODULES = {
77
{"src", "Library"},
8-
{"modules/testez/lib", "TestEZ"},
8+
{"modules/testez/src", "TestEZ"},
99
}
1010

1111
-- This makes sure we can load Lemur and other libraries that depend on init.lua
@@ -31,7 +31,10 @@ end
3131
-- Load TestEZ and run our tests
3232
local TestEZ = habitat:require(Root.TestEZ)
3333

34-
local results = TestEZ.TestBootstrap:run(Root.Library, TestEZ.Reporters.TextReporter)
34+
local results = TestEZ.TestBootstrap:run(
35+
{ Root.Library },
36+
TestEZ.Reporters.TextReporter
37+
)
3538

3639
-- Did something go wrong?
3740
if results.failureCount > 0 then

src/NoYield.lua

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
--!nocheck
2+
13
--[[
24
Calls a function and throws an error if it attempts to yield.
35
@@ -26,4 +28,4 @@ local function NoYield(callback, ...)
2628
return resultHandler(co, coroutine.resume(co, ...))
2729
end
2830

29-
return NoYield
31+
return NoYield

src/Signal.lua

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
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
78

89
local function immutableAppend(list, ...)
910
local new = {}
@@ -36,9 +37,10 @@ local Signal = {}
3637

3738
Signal.__index = Signal
3839

39-
function Signal.new()
40+
function Signal.new(store)
4041
local self = {
41-
_listeners = {}
42+
_listeners = {},
43+
_store = store
4244
}
4345

4446
setmetatable(self, Signal)
@@ -47,15 +49,45 @@ function Signal.new()
4749
end
4850

4951
function Signal:connect(callback)
52+
if typeof(callback) ~= "function" then
53+
error("Expected the listener to be a function.")
54+
end
55+
56+
if self._store and self._store._isDispatching then
57+
error(
58+
'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. '
61+
)
62+
end
63+
5064
local listener = {
5165
callback = callback,
5266
disconnected = false,
67+
connectTraceback = debug.traceback(),
68+
disconnectTraceback = nil
5369
}
5470

5571
self._listeners = immutableAppend(self._listeners, listener)
5672

5773
local function disconnect()
74+
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
83+
end
84+
85+
if self._store and self._store._isDispatching then
86+
error("You may not unsubscribe from a store listener while the reducer is executing.")
87+
end
88+
5889
listener.disconnected = true
90+
listener.disconnectTraceback = debug.traceback()
5991
self._listeners = immutableRemoveValue(self._listeners, listener)
6092
end
6193

@@ -64,10 +96,35 @@ function Signal:connect(callback)
6496
}
6597
end
6698

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+
67115
function Signal:fire(...)
68116
for _, listener in ipairs(self._listeners) do
69117
if not listener.disconnected then
70-
listener.callback(...)
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
71128
end
72129
end
73130
end

src/Signal.spec.lua

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,85 @@ return function()
111111
expect(countA).to.equal(1)
112112
expect(countB).to.equal(0)
113113
end)
114+
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)
134+
135+
it("first listener succeeds when second listener errors", function()
136+
local signal = Signal.new(mockStore)
137+
local countA = 0
138+
139+
signal:connect(function()
140+
countA = countA + 1
141+
end)
142+
143+
signal:connect(function()
144+
error("connectionB")
145+
end)
146+
147+
signal:fire()
148+
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()
154+
end)
155+
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+
168+
signal:fire()
169+
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})
186+
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()
193+
end)
194+
end)
114195
end

src/Store.lua

Lines changed: 85 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@ 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
6+
7+
local defaultErrorReporter = {
8+
reportErrorDeferred = function(self, message, stacktrace)
9+
print(message)
10+
print(stacktrace)
11+
end,
12+
reportErrorImmediately = function(self, message, stacktrace)
13+
print(message)
14+
print(stacktrace)
15+
end
16+
}
517

618
local Store = {}
719

@@ -23,22 +35,41 @@ Store.__index = Store
2335
Reducers do not mutate the state object, so the original state is still
2436
valid.
2537
]]
26-
function Store.new(reducer, initialState, middlewares)
38+
function Store.new(reducer, initialState, middlewares, errorReporter)
2739
assert(typeof(reducer) == "function", "Bad argument #1 to Store.new, expected function.")
2840
assert(middlewares == nil or typeof(middlewares) == "table", "Bad argument #3 to Store.new, expected nil or table.")
41+
if middlewares ~= nil then
42+
for i=1, #middlewares, 1 do
43+
assert(
44+
typeof(middlewares[i]) == "function",
45+
("Expected the middleware ('%s') at index %d to be a function."):format(tostring(middlewares[i]), i)
46+
)
47+
end
48+
end
2949

3050
local self = {}
3151

52+
self._errorReporter = errorReporter or defaultErrorReporter
53+
self._isDispatching = false
3254
self._reducer = reducer
33-
self._state = reducer(initialState, {
55+
local initAction = {
3456
type = "@@INIT",
35-
})
57+
}
58+
self._lastAction = initAction
59+
local ok, result = pcall(function()
60+
self._state = reducer(initialState, initAction)
61+
end)
62+
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())
65+
self._state = initialState
66+
end
3667
self._lastState = self._state
3768

3869
self._mutatedSinceFlush = false
3970
self._connections = {}
4071

41-
self.changed = Signal.new()
72+
self.changed = Signal.new(self)
4273

4374
setmetatable(self, Store)
4475

@@ -58,7 +89,7 @@ function Store.new(reducer, initialState, middlewares)
5889
dispatch = middleware(dispatch, self)
5990
end
6091

61-
self.dispatch = function(self, ...)
92+
self.dispatch = function(_self, ...)
6293
return dispatch(...)
6394
end
6495
end
@@ -70,9 +101,29 @@ end
70101
Get the current state of the Store. Do not mutate this!
71102
]]
72103
function Store:getState()
104+
if self._isDispatching then
105+
error(("You may not call store:getState() while the reducer is executing. " ..
106+
"The reducer (%s) has already received the state as an argument. " ..
107+
"Pass it down from the top reducer instead of reading it from the store."):format(tostring(self._reducer)))
108+
end
109+
73110
return self._state
74111
end
75112

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+
76127
--[[
77128
Dispatch an action to the store. This allows the store's reducer to mutate
78129
the state of the application by creating a new copy of the state.
@@ -81,16 +132,39 @@ end
81132
changes, but not necessarily on every Dispatch.
82133
]]
83134
function Store:dispatch(action)
84-
if typeof(action) == "table" then
85-
if action.type == nil then
86-
error("action does not have a type field", 2)
87-
end
135+
if typeof(action) ~= "table" then
136+
error(("Actions must be tables. " ..
137+
"Use custom middleware for %q actions."):format(typeof(action)),
138+
2
139+
)
140+
end
88141

142+
if action.type == nil then
143+
error("Actions may not have an undefined 'type' property. " ..
144+
"Have you misspelled a constant? \n" ..
145+
inspect(action), 2)
146+
end
147+
148+
if self._isDispatching then
149+
error("Reducers may not dispatch actions.")
150+
end
151+
152+
local ok, result = pcall(function()
153+
self._isDispatching = true
89154
self._state = self._reducer(self._state, action)
90155
self._mutatedSinceFlush = true
91-
else
92-
error(("actions of type %q are not permitted"):format(typeof(action)), 2)
156+
end)
157+
158+
self._isDispatching = false
159+
160+
if not ok then
161+
self:_reportReducerError(
162+
action,
163+
result,
164+
debug.traceback()
165+
)
93166
end
167+
self._lastAction = action
94168
end
95169

96170
--[[

0 commit comments

Comments
 (0)