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

Error boundaries! #173

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 40 additions & 11 deletions lib/Component.lua
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,44 @@ function Component:render()
error(message, 0)
end

--[[
Tries to update the component's children, correctly handling the presence of
an error boundary.
]]
function Component:__updateChildren(reconciler, virtualNode)
AmaranthineCodices marked this conversation as resolved.
Show resolved Hide resolved
local hostParent = virtualNode.hostParent
local instance = virtualNode.instance
local internalData = instance[InternalData]

internalData.setStateBlockedReason = "render"
local renderResult = instance:render()
internalData.setStateBlockedReason = nil

-- Only bother with pcall if we actually have something to handle the error.
if self.getDerivedStateFromError ~= nil then
local success, message = pcall(reconciler.updateVirtualNodeWithRenderResult, virtualNode, hostParent, renderResult)

if not success then
local stateDelta = self.getDerivedStateFromError(message)
-- Use setState here to preserve any semantics that setState has.
-- This also means getDerivedStateFromError can return a mapper
-- function, not just a delta table.
internalData.setStateShouldSkipUpdate = true
instance:setState(stateDelta)
internalData.setStateShouldSkipUpdate = false

internalData.setStateBlockedReason = "render"
renderResult = instance:render()
internalData.setStateBlockedReason = nil
-- Don't try to handle errors at this point - the component should
-- be in a position to render a non-throwing fallback by now.
reconciler.updateVirtualNodeWithRenderResult(virtualNode, hostParent, renderResult)
end
else
reconciler.updateVirtualNodeWithRenderResult(virtualNode, hostParent, renderResult)
end
end
Copy link
Contributor

Choose a reason for hiding this comment

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

It just occurred to me that this implementation doesn't actually catch errors if they occur downstream of this call site.
For example, suppose I have:

ComponentA
|--ErrorBound
|----ComponentB

Consider these scenarios:

  1. ComponentA renders, causing ComponentB to render. ComponentB throws an error. The ErrorBound catches and handles it just fine.
  2. After all of this is mounted, ComponentB has a state update triggered, and re-renders. ComponentB throws an error. The ErrorBound won't be involved in reconciling it, so it won't be invoked!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I need to revisit this when I have the time - there are definitely cases that error boundaries should catch, but don't.


--[[
An internal method used by the reconciler to construct a new component
instance and attach it to the given virtualNode.
Expand All @@ -131,7 +169,6 @@ function Component:__mount(reconciler, virtualNode)
assert(Type.of(virtualNode) == Type.VirtualNode)

local currentElement = virtualNode.currentElement
local hostParent = virtualNode.hostParent

-- Contains all the information that we want to keep from consumers of
-- Roact, or even other parts of the codebase like the reconciler.
Expand Down Expand Up @@ -184,11 +221,7 @@ function Component:__mount(reconciler, virtualNode)
-- It's possible for init() to redefine _context!
virtualNode.context = instance._context

internalData.setStateBlockedReason = "render"
local renderResult = instance:render()
internalData.setStateBlockedReason = nil

reconciler.updateVirtualNodeWithRenderResult(virtualNode, hostParent, renderResult)
self:__updateChildren(reconciler, virtualNode)

if instance.didMount ~= nil then
instance:didMount()
Expand Down Expand Up @@ -285,11 +318,7 @@ function Component:__update(updatedElement, updatedState)
self.props = newProps
self.state = newState

internalData.setStateBlockedReason = "render"
local renderResult = virtualNode.instance:render()
internalData.setStateBlockedReason = nil

reconciler.updateVirtualNodeWithRenderResult(virtualNode, virtualNode.hostParent, renderResult)
self:__updateChildren(reconciler, virtualNode)

if self.didUpdate ~= nil then
self:didUpdate(oldProps, oldState)
Expand Down
190 changes: 190 additions & 0 deletions lib/Component.spec/getDerivedStateFromError.spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
return function()
local createElement = require(script.Parent.Parent.createElement)
local createReconciler = require(script.Parent.Parent.createReconciler)
local NoopRenderer = require(script.Parent.Parent.NoopRenderer)

local Component = require(script.Parent.Parent.Component)

local noopReconciler = createReconciler(NoopRenderer)

it("should not be called if the component doesn't throw", function()
local callCount = 0

local Boundary = Component:extend("Boundary")

function Boundary.getDerivedStateFromError(message)
callCount = callCount + 1
end

function Boundary:render()
return nil
end

local element = createElement(Boundary)
local hostParent = nil
local key = "Test"

noopReconciler.mountVirtualNode(element, hostParent, key)
expect(callCount).to.equal(0)
end)

it("should be called with the error message", function()
local callCount = 0

local function Bug()
error("test error")
end

local Boundary = Component:extend("Boundary")

function Boundary.getDerivedStateFromError(message)
callCount = callCount + 1
-- the error message will not be the same as what's thrown because a
-- line/source object as well as stack trace will be attached to it
expect(message).to.be.ok()

return {}
end

function Boundary:render()
return createElement(Bug)
end

local element = createElement(Boundary)
local hostParent = nil
local key = "Test"

-- This will throw, because we don't stop rendering the throwing
-- component in response to the error. We test this more
-- rigorously elsewhere; this is just to keep the test from failing.
expect(function()
noopReconciler.mountVirtualNode(element, hostParent, key)
end).to.throw()
expect(callCount).to.equal(1)
end)
AmaranthineCodices marked this conversation as resolved.
Show resolved Hide resolved

it("should throw an error if the fallback render throws", function()
local renderCount = 0

local function Bug()
error("test error")
end

local Boundary = Component:extend("Boundary")

function Boundary.getDerivedStateFromError(message)
return {}
end

function Boundary:render()
renderCount = renderCount + 1
return createElement(Bug)
end

local element = createElement(Boundary)
local hostParent = nil
local key = "Test"

expect(function()
noopReconciler.mountVirtualNode(element, hostParent, key)
end).to.throw()
expect(renderCount).to.equal(2)
end)

it("should return a state delta for the component", function()
local renderCount = 0
local getStateCallback = nil

local function Bug()
error("test error")
end

local Boundary = Component:extend("Boundary")

function Boundary.getDerivedStateFromError(message)
return {
errored = true
}
end

function Boundary:init()
getStateCallback = function()
return self.state
end
end

function Boundary:render()
renderCount = renderCount + 1
if renderCount > 1 then
expect(self.state.errored).to.equal(true)
end

if self.state.errored then
return nil
else
return createElement(Bug)
end
end

local element = createElement(Boundary)
local hostParent = nil
local key = "Test"

noopReconciler.mountVirtualNode(element, hostParent, key)
expect(renderCount).to.equal(2)
expect(getStateCallback().errored).to.equal(true)
end)

it("should not interrupt the lifecycle methods", function()
local didMountCount = 0
local didUpdateCount = 0
local setStateCallback = nil

local function Bug()
error("test error")
end

local Boundary = Component:extend("Boundary")

function Boundary.getDerivedStateFromError(message)
return {
errored = true
}
end

function Boundary:init()
setStateCallback = function(delta)
self:setState(delta)
end
end

function Boundary:render()
if self.state.errored then
return nil
else
return createElement(Bug)
end
end

function Boundary:didMount()
didMountCount = didMountCount + 1
end

function Boundary:didUpdate()
didUpdateCount = didUpdateCount + 1
end

local element = createElement(Boundary)
local hostParent = nil
local key = "Test"

noopReconciler.mountVirtualNode(element, hostParent, key)
expect(didMountCount).to.equal(1)
expect(didUpdateCount).to.equal(0)
setStateCallback({
errored = false
})

expect(didUpdateCount).to.equal(1)
end)
end