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

More setState checks #26

Merged
merged 9 commits into from
Feb 17, 2018
4 changes: 3 additions & 1 deletion docs/pages/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ end
function TestComponent:willUnmount()
print("We're about to unmount!")
end
```
```

**Note:** If you are calling `setState` within `didMount` or `didUpdate`, make sure that you are not calling `setState` unconditionally. If `setState` is called every time `didMount` or `didUpdate` is called, you will cause a stack overflow error.
25 changes: 17 additions & 8 deletions lib/Component.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ local Component = {}

Component.__index = Component

-- The error message that is thrown when setState is called in the wrong place.
-- This is declared here to avoid really messy indentation.
local INVALID_SETSTATE_MESSAGE = [[
setState cannot be used currently, are you calling setState from any of:
* the willUpdate or willUnmount lifecycle hooks
* the init function
* the render function
* the shouldUpdate function]]

--[[
Create a new Roact stateful component class.

Expand Down Expand Up @@ -106,12 +115,9 @@ end
current state object.
]]
function Component:setState(partialState)
-- State cannot be set in any of the following places:
-- * During the component's init function
-- * During the component's render function
-- * After the component has been unmounted (or is in the process of unmounting, e.g. willUnmount)
-- State cannot be set in any lifecycle hooks.
if not self._canSetState then
error("setState cannot be used currently: are you calling setState from an init, render, or willUnmount function?", 0)
error(INVALID_SETSTATE_MESSAGE, 0)
end

local newState = {}
Expand All @@ -134,7 +140,9 @@ end
reconciliation step.
]]
function Component:_update(newProps, newState)
self._canSetState = false
local willUpdate = self:shouldUpdate(newProps or self.props, newState or self.state)
self._canSetState = true

if willUpdate then
self:_forceUpdate(newProps, newState)
Expand All @@ -147,6 +155,7 @@ end
newProps and newState are optional.
]]
function Component:_forceUpdate(newProps, newState)
self._canSetState = false
if self.willUpdate then
self:willUpdate(newProps or self.props, newState or self.state)
end
Expand All @@ -162,9 +171,7 @@ function Component:_forceUpdate(newProps, newState)
self.state = newState
end

self._canSetState = false
local newChildElement = self:render()
self._canSetState = true

if self._handle._reified ~= nil then
-- We returned an element before, update it.
Expand All @@ -182,6 +189,8 @@ function Component:_forceUpdate(newProps, newState)
)
end

self._canSetState = true
Copy link
Contributor

Choose a reason for hiding this comment

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

An interesting implication here: the state of your parent can't have its state updated while you're in the middle of updating. Calling a function in your didUpdate callback that indirectly invokes setState on a parent component would thus fail.

I wonder if that's a case that we hit in real code, and if that's a bug!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This will only throw an error if this setState indirection happens synchronously. If you spawn another thread and trigger the parent setState call there, or do it from an event connection, there won't be an error.

Since most use-cases I can see for calling setState in didUpdate involve some sort of blocking operation (that stalls out the entire render until it's complete if it's done synchronously anyway), I don't think this is a major issue.

Hypothetically this could be worked around by deferring didUpdate invocations until after the entire tree has re-rendered, parents and all, but I don't think this is worth the effort.


if self.didUpdate then
self:didUpdate(oldProps, oldState)
end
Expand Down Expand Up @@ -209,4 +218,4 @@ function Component:_reify(handle)
end
end

return Component
return Component
63 changes: 63 additions & 0 deletions lib/Component.spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ return function()
})
end

function InitComponent:render()
return nil
end
Copy link
Contributor

Choose a reason for hiding this comment

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

This is an interesting artifact of a test that's only as coarse as "this high level call should throw". I wonder if there's a way to make these kinds of tests more robust in the future.


local initElement = Core.createElement(InitComponent)

expect(function()
Expand All @@ -207,6 +211,65 @@ return function()
end).to.throw()
end)

it("should throw when called in shouldUpdate", function()
local TestComponent = Component:extend("TestComponent")

local triggerTest

function TestComponent:init()
triggerTest = function()
self:setState({
a = 1
})
end
end

function TestComponent:render()
return nil
end

function TestComponent:shouldUpdate()
self:setState({
a = 1
})
end

local testElement = Core.createElement(TestComponent)

expect(function()
Reconciler.reify(testElement)
triggerTest()
end).to.throw()
end)

it("should throw when called in willUpdate", function()
local TestComponent = Component:extend("TestComponent")
local forceUpdate

function TestComponent:init()
forceUpdate = function()
self:_forceUpdate()
end
end

function TestComponent:render()
return nil
end

function TestComponent:willUpdate()
self:setState({
a = 1
})
end

local testElement = Core.createElement(TestComponent)

expect(function()
Reconciler.reify(testElement)
forceUpdate()
end).to.throw()
end)

it("should throw when called in willUnmount", function()
local TestComponent = Component:extend("TestComponent")

Expand Down