add gs
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
--[[
|
||||
Change is used to generate special prop keys that can be used to connect to
|
||||
GetPropertyChangedSignal.
|
||||
|
||||
Generally, Change is indexed by a Roblox property name:
|
||||
|
||||
Roact.createElement("TextBox", {
|
||||
[Roact.Change.Text] = function(rbx)
|
||||
print("The TextBox", rbx, "changed text to", rbx.Text)
|
||||
end,
|
||||
})
|
||||
]]
|
||||
|
||||
local Change = {}
|
||||
|
||||
local changeMetatable = {
|
||||
__tostring = function(self)
|
||||
return ("ChangeListener(%s)"):format(self.name)
|
||||
end
|
||||
}
|
||||
|
||||
setmetatable(Change, {
|
||||
__index = function(self, propertyName)
|
||||
local changeListener = {
|
||||
type = Change,
|
||||
name = propertyName
|
||||
}
|
||||
|
||||
setmetatable(changeListener, changeMetatable)
|
||||
Change[propertyName] = changeListener
|
||||
|
||||
return changeListener
|
||||
end,
|
||||
})
|
||||
|
||||
return Change
|
||||
@@ -0,0 +1,15 @@
|
||||
return function()
|
||||
local Change = require(script.Parent.Change)
|
||||
|
||||
it("should yield change listener objects when indexed", function()
|
||||
expect(Change.Text).to.be.ok()
|
||||
expect(Change.Selected).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should yield the same object when indexed again", function()
|
||||
local a = Change.Text
|
||||
local b = Change.Text
|
||||
|
||||
expect(a).to.equal(b)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,435 @@
|
||||
--[[
|
||||
The base implementation of a stateful component in Roact.
|
||||
|
||||
Stateful components handle most of their own mounting and reconciliation
|
||||
process. Many of the private methods here are invoked by the reconciler.
|
||||
|
||||
Stateful components expose a handful of lifecycle events:
|
||||
- didMount
|
||||
- willUnmount
|
||||
- willUpdate
|
||||
- didUpdate
|
||||
- (static) getDerivedStateFromProps
|
||||
|
||||
These lifecycle events line up with their semantics in React, and more
|
||||
information (and a diagram) is available in the Roact documentation.
|
||||
]]
|
||||
|
||||
local Reconciler = require(script.Parent.Reconciler)
|
||||
local Core = require(script.Parent.Core)
|
||||
local GlobalConfig = require(script.Parent.GlobalConfig)
|
||||
local Instrumentation = require(script.Parent.Instrumentation)
|
||||
|
||||
local invalidSetStateMessages = require(script.Parent.invalidSetStateMessages)
|
||||
|
||||
local Component = {}
|
||||
|
||||
-- Locally cache tick so we can minimize impact of calling it for instrumentation
|
||||
local tick = tick
|
||||
|
||||
Component.__index = Component
|
||||
|
||||
--[[
|
||||
Merge any number of dictionaries into a new dictionary, overwriting keys.
|
||||
|
||||
If a value of `Core.None` is encountered, the key will be removed instead.
|
||||
This is necessary because Lua doesn't differentiate between a key being
|
||||
missing and a key being set to nil.
|
||||
]]
|
||||
local function merge(...)
|
||||
local result = {}
|
||||
|
||||
for i = 1, select("#", ...) do
|
||||
local entry = select(i, ...)
|
||||
|
||||
for key, value in pairs(entry) do
|
||||
if value == Core.None then
|
||||
result[key] = nil
|
||||
else
|
||||
result[key] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
--[[
|
||||
Create a new stateful component.
|
||||
|
||||
Not intended to be a general OO implementation, this function only intends
|
||||
to let users extend Component and PureComponent.
|
||||
|
||||
Instead of using inheritance, use composition and props to extend
|
||||
components.
|
||||
]]
|
||||
function Component:extend(name)
|
||||
assert(type(name) == "string", "A name must be provided to create a Roact Component")
|
||||
|
||||
local class = {}
|
||||
|
||||
for key, value in pairs(self) do
|
||||
-- We don't want users using 'extend' to create component inheritance
|
||||
-- see https://reactjs.org/docs/composition-vs-inheritance.html
|
||||
if key ~= "extend" then
|
||||
class[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
class.__index = class
|
||||
|
||||
setmetatable(class, {
|
||||
__tostring = function(self)
|
||||
return name
|
||||
end
|
||||
})
|
||||
|
||||
function class._new(props, context)
|
||||
local self = {}
|
||||
|
||||
-- When set to a value, setState will fail, using the given reason to
|
||||
-- create a detailed error message.
|
||||
-- You can see a list of reasons in invalidSetStateMessages.
|
||||
self._setStateBlockedReason = nil
|
||||
|
||||
if class.defaultProps == nil then
|
||||
self.props = props
|
||||
else
|
||||
self.props = merge(class.defaultProps, props)
|
||||
end
|
||||
|
||||
self._context = {}
|
||||
|
||||
-- Shallow copy all context values from our parent element.
|
||||
if context then
|
||||
for key, value in pairs(context) do
|
||||
self._context[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
setmetatable(self, class)
|
||||
|
||||
-- Call the user-provided initializer, where state and _props are set.
|
||||
if class.init then
|
||||
self._setStateBlockedReason = "init"
|
||||
class.init(self, props)
|
||||
self._setStateBlockedReason = nil
|
||||
end
|
||||
|
||||
-- The user constructer might not set state, so we can.
|
||||
if not self.state then
|
||||
self.state = {}
|
||||
end
|
||||
|
||||
if class.getDerivedStateFromProps then
|
||||
local partialState = class.getDerivedStateFromProps(props, self.state)
|
||||
|
||||
if partialState then
|
||||
self.state = merge(self.state, partialState)
|
||||
end
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
return class
|
||||
end
|
||||
|
||||
--[[
|
||||
render is intended to describe what a UI should look like at the current
|
||||
point in time.
|
||||
|
||||
The default implementation throws an error, since forgetting to define
|
||||
render is usually a mistake.
|
||||
|
||||
The simplest implementation for render is:
|
||||
|
||||
function MyComponent:render()
|
||||
return nil
|
||||
end
|
||||
|
||||
You should explicitly return nil from functions in Lua to avoid edge cases
|
||||
related to none versus nil.
|
||||
]]
|
||||
function Component:render()
|
||||
local message = (
|
||||
"The component %q is missing the 'render' method.\n" ..
|
||||
"render must be defined when creating a Roact component!"
|
||||
):format(
|
||||
tostring(getmetatable(self))
|
||||
)
|
||||
|
||||
error(message, 0)
|
||||
end
|
||||
|
||||
--[[
|
||||
Used to tell Roact whether this component *might* need to be re-rendered
|
||||
given a new set of props and state.
|
||||
|
||||
This method is an escape hatch for when the Roact element creation and
|
||||
reconciliation algorithms are not fast enough for specific cases. Poorly
|
||||
written shouldUpdate methods *will* cause hard-to-trace bugs.
|
||||
|
||||
If you're thinking of writing a shouldUpdate function, consider using
|
||||
PureComponent instead, which provides a good implementation given that your
|
||||
data is immutable.
|
||||
|
||||
This function must be faster than the render method in order to be a
|
||||
performance improvement.
|
||||
]]
|
||||
function Component:shouldUpdate(newProps, newState)
|
||||
return true
|
||||
end
|
||||
|
||||
--[[
|
||||
Applies new state to the component.
|
||||
|
||||
partialState may be one of two things:
|
||||
- A table, which will be merged onto the current state.
|
||||
- A function, returning a table to merge onto the current state.
|
||||
|
||||
The table variant generally looks like:
|
||||
|
||||
self:setState({
|
||||
foo = "bar",
|
||||
})
|
||||
|
||||
The function variant generally looks like:
|
||||
|
||||
self:setState(function(prevState, props)
|
||||
return {
|
||||
foo = prevState.count + 1,
|
||||
})
|
||||
end)
|
||||
|
||||
The function variant may also return nil in the callback, which allows Roact
|
||||
to cancel updating state and abort the render.
|
||||
|
||||
Future versions of Roact will potentially batch or delay state merging, so
|
||||
any state updates that depend on the current state should use the function
|
||||
variant.
|
||||
]]
|
||||
function Component:setState(partialState)
|
||||
-- If setState was disabled, we should check for a detailed message and
|
||||
-- display it.
|
||||
if self._setStateBlockedReason ~= nil then
|
||||
local messageSource = invalidSetStateMessages[self._setStateBlockedReason]
|
||||
|
||||
if messageSource == nil then
|
||||
messageSource = invalidSetStateMessages["default"]
|
||||
end
|
||||
|
||||
-- We assume that each message has a formatting placeholder for a component name.
|
||||
local formattedMessage = string.format(messageSource, tostring(getmetatable(self)))
|
||||
|
||||
error(formattedMessage, 2)
|
||||
end
|
||||
|
||||
-- If the partial state is a function, invoke it to get the actual partial state.
|
||||
if type(partialState) == "function" then
|
||||
partialState = partialState(self.state, self.props)
|
||||
|
||||
-- If partialState is nil, abort the render.
|
||||
if partialState == nil then
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local newState = merge(self.state, partialState)
|
||||
self:_update(nil, newState)
|
||||
end
|
||||
|
||||
--[[
|
||||
Returns the current stack trace for this component, or nil if the
|
||||
elementTracing configuration flag is set to false.
|
||||
]]
|
||||
function Component:getElementTraceback()
|
||||
return self._handle._element.source
|
||||
end
|
||||
|
||||
--[[
|
||||
Notifies the component that new props and state are available. This function
|
||||
is invoked by the reconciler.
|
||||
|
||||
If shouldUpdate returns true, this method will trigger a re-render and
|
||||
reconciliation step.
|
||||
]]
|
||||
function Component:_update(newProps, newState)
|
||||
self._setStateBlockedReason = "shouldUpdate"
|
||||
|
||||
local doUpdate
|
||||
if GlobalConfig.getValue("componentInstrumentation") then
|
||||
local startTime = tick()
|
||||
|
||||
doUpdate = self:shouldUpdate(newProps or self.props, newState or self.state)
|
||||
|
||||
local elapsed = tick() - startTime
|
||||
Instrumentation.logShouldUpdate(self._handle, doUpdate, elapsed)
|
||||
else
|
||||
doUpdate = self:shouldUpdate(newProps or self.props, newState or self.state)
|
||||
end
|
||||
|
||||
self._setStateBlockedReason = nil
|
||||
|
||||
if doUpdate then
|
||||
self:_forceUpdate(newProps, newState)
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Forces the component to re-render itself and its children.
|
||||
|
||||
This is essentially the inner portion of _update.
|
||||
|
||||
newProps and newState are optional.
|
||||
]]
|
||||
function Component:_forceUpdate(newProps, newState)
|
||||
-- Compute new derived state.
|
||||
-- Get the class - getDerivedStateFromProps is static.
|
||||
local class = getmetatable(self)
|
||||
|
||||
-- If newProps are passed, compute derived state and default props
|
||||
if newProps then
|
||||
if class.getDerivedStateFromProps then
|
||||
local derivedState = class.getDerivedStateFromProps(newProps, newState or self.state)
|
||||
|
||||
-- getDerivedStateFromProps can return nil if no changes are necessary.
|
||||
if derivedState ~= nil then
|
||||
newState = merge(newState or self.state, derivedState)
|
||||
end
|
||||
end
|
||||
|
||||
if class.defaultProps then
|
||||
-- We only allocate another prop table if there are props that are
|
||||
-- falling back to their default.
|
||||
local replacementProps
|
||||
|
||||
for key in pairs(class.defaultProps) do
|
||||
if newProps[key] == nil then
|
||||
replacementProps = merge(class.defaultProps, newProps)
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if replacementProps then
|
||||
newProps = replacementProps
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if self.willUpdate then
|
||||
self._setStateBlockedReason = "willUpdate"
|
||||
self:willUpdate(newProps or self.props, newState or self.state)
|
||||
self._setStateBlockedReason = nil
|
||||
end
|
||||
|
||||
local oldProps = self.props
|
||||
local oldState = self.state
|
||||
|
||||
if newProps then
|
||||
self.props = newProps
|
||||
end
|
||||
|
||||
if newState then
|
||||
self.state = newState
|
||||
end
|
||||
|
||||
self._setStateBlockedReason = "render"
|
||||
|
||||
local newChildElement
|
||||
if GlobalConfig.getValue("componentInstrumentation") then
|
||||
local startTime = tick()
|
||||
|
||||
newChildElement = self:render()
|
||||
|
||||
local elapsed = tick() - startTime
|
||||
Instrumentation.logRenderTime(self._handle, elapsed)
|
||||
else
|
||||
newChildElement = self:render()
|
||||
end
|
||||
|
||||
self._setStateBlockedReason = nil
|
||||
|
||||
self._setStateBlockedReason = "reconcile"
|
||||
if self._handle._child ~= nil then
|
||||
-- We returned an element during our last render, update it.
|
||||
self._handle._child = Reconciler._reconcileInternal(
|
||||
self._handle._child,
|
||||
newChildElement
|
||||
)
|
||||
elseif newChildElement then
|
||||
-- We returned nil during our last render, construct a new child.
|
||||
self._handle._child = Reconciler._mountInternal(
|
||||
newChildElement,
|
||||
self._handle._parent,
|
||||
self._handle._key,
|
||||
self._context
|
||||
)
|
||||
end
|
||||
self._setStateBlockedReason = nil
|
||||
|
||||
if self.didUpdate then
|
||||
self:didUpdate(oldProps, oldState)
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Initializes the component instance and attaches it to the given
|
||||
instance handle, created by Reconciler._mount.
|
||||
]]
|
||||
function Component:_mount(handle)
|
||||
self._handle = handle
|
||||
|
||||
self._setStateBlockedReason = "render"
|
||||
|
||||
local virtualTree
|
||||
if GlobalConfig.getValue("componentInstrumentation") then
|
||||
local startTime = tick()
|
||||
|
||||
virtualTree = self:render()
|
||||
|
||||
local elapsed = tick() - startTime
|
||||
Instrumentation.logRenderTime(self._handle, elapsed)
|
||||
else
|
||||
virtualTree = self:render()
|
||||
end
|
||||
|
||||
self._setStateBlockedReason = nil
|
||||
|
||||
if virtualTree then
|
||||
self._setStateBlockedReason = "reconcile"
|
||||
handle._child = Reconciler._mountInternal(
|
||||
virtualTree,
|
||||
handle._parent,
|
||||
handle._key,
|
||||
self._context
|
||||
)
|
||||
self._setStateBlockedReason = nil
|
||||
end
|
||||
|
||||
if self.didMount then
|
||||
self:didMount()
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Destructs the component and invokes all necessary lifecycle methods.
|
||||
]]
|
||||
function Component:_unmount()
|
||||
local handle = self._handle
|
||||
|
||||
if self.willUnmount then
|
||||
self._setStateBlockedReason = "willUnmount"
|
||||
self:willUnmount()
|
||||
self._setStateBlockedReason = nil
|
||||
end
|
||||
|
||||
-- Stateful components can return nil from render()
|
||||
if handle._child then
|
||||
Reconciler.unmount(handle._child)
|
||||
end
|
||||
|
||||
self._handle = nil
|
||||
end
|
||||
|
||||
return Component
|
||||
@@ -0,0 +1,615 @@
|
||||
return function()
|
||||
local Core = require(script.Parent.Core)
|
||||
local createElement = require(script.Parent.createElement)
|
||||
local Reconciler = require(script.Parent.Reconciler)
|
||||
local GlobalConfig = require(script.Parent.GlobalConfig)
|
||||
|
||||
local Component = require(script.Parent.Component)
|
||||
|
||||
it("should be extendable", function()
|
||||
local MyComponent = Component:extend("The Senate")
|
||||
|
||||
expect(MyComponent).to.be.ok()
|
||||
expect(MyComponent._new).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should prevent extending a user component", function()
|
||||
local MyComponent = Component:extend("Sheev")
|
||||
|
||||
expect(function()
|
||||
MyComponent:extend("Frank")
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should use a given name", function()
|
||||
local MyComponent = Component:extend("FooBar")
|
||||
|
||||
local name = tostring(MyComponent)
|
||||
|
||||
expect(name).to.be.a("string")
|
||||
expect(name:find("FooBar")).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should throw on render with a useful message by default", function()
|
||||
local MyComponent = Component:extend("Foo")
|
||||
|
||||
local instance = MyComponent._new({})
|
||||
|
||||
expect(instance).to.be.ok()
|
||||
|
||||
local ok, err = pcall(function()
|
||||
instance:render()
|
||||
end)
|
||||
|
||||
expect(ok).to.equal(false)
|
||||
expect(err:find("Foo")).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should pass props to the initializer", function()
|
||||
local MyComponent = Component:extend("Wazo")
|
||||
|
||||
local callCount = 0
|
||||
local testProps = {}
|
||||
|
||||
function MyComponent:init(props)
|
||||
expect(props).to.equal(testProps)
|
||||
callCount = callCount + 1
|
||||
end
|
||||
|
||||
MyComponent._new(testProps)
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should fire didMount and willUnmount when reified", function()
|
||||
local MyComponent = Component:extend("MyComponent")
|
||||
local mounts = 0
|
||||
local unmounts = 0
|
||||
|
||||
function MyComponent:render()
|
||||
return nil
|
||||
end
|
||||
|
||||
function MyComponent:didMount()
|
||||
mounts = mounts + 1
|
||||
end
|
||||
|
||||
function MyComponent:willUnmount()
|
||||
unmounts = unmounts + 1
|
||||
end
|
||||
|
||||
expect(mounts).to.equal(0)
|
||||
expect(unmounts).to.equal(0)
|
||||
|
||||
local instance = Reconciler.mount(createElement(MyComponent))
|
||||
|
||||
expect(mounts).to.equal(1)
|
||||
expect(unmounts).to.equal(0)
|
||||
|
||||
Reconciler.unmount(instance)
|
||||
|
||||
expect(mounts).to.equal(1)
|
||||
expect(unmounts).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should provide the proper arguments to willUpdate and didUpdate", function()
|
||||
local willUpdateCount = 0
|
||||
local didUpdateCount = 0
|
||||
local prevProps
|
||||
local prevState
|
||||
local nextProps
|
||||
local nextState
|
||||
local setValue
|
||||
|
||||
local Child = Component:extend("PureChild")
|
||||
|
||||
function Child:willUpdate(newProps, newState)
|
||||
nextProps = assert(newProps)
|
||||
nextState = assert(newState)
|
||||
prevProps = assert(self.props)
|
||||
prevState = assert(self.state)
|
||||
willUpdateCount = willUpdateCount + 1
|
||||
end
|
||||
|
||||
function Child:didUpdate(oldProps, oldState)
|
||||
assert(oldProps)
|
||||
assert(oldState)
|
||||
expect(prevProps.value).to.equal(oldProps.value)
|
||||
expect(prevState.value).to.equal(oldState.value)
|
||||
expect(nextProps.value).to.equal(self.props.value)
|
||||
expect(nextState.value).to.equal(self.state.value)
|
||||
didUpdateCount = didUpdateCount + 1
|
||||
end
|
||||
|
||||
function Child:render()
|
||||
return nil
|
||||
end
|
||||
|
||||
local Container = Component:extend("Container")
|
||||
|
||||
function Container:init()
|
||||
self.state = {
|
||||
value = 0,
|
||||
}
|
||||
end
|
||||
|
||||
function Container:didMount()
|
||||
setValue = function(value)
|
||||
self:setState({
|
||||
value = value,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function Container:willUnmount()
|
||||
setValue = nil
|
||||
end
|
||||
|
||||
function Container:render()
|
||||
return createElement(Child, {
|
||||
value = self.state.value,
|
||||
})
|
||||
end
|
||||
|
||||
local element = createElement(Container)
|
||||
local instance = Reconciler.mount(element)
|
||||
|
||||
expect(willUpdateCount).to.equal(0)
|
||||
expect(didUpdateCount).to.equal(0)
|
||||
|
||||
setValue(1)
|
||||
|
||||
expect(willUpdateCount).to.equal(1)
|
||||
expect(didUpdateCount).to.equal(1)
|
||||
|
||||
setValue(1)
|
||||
|
||||
expect(willUpdateCount).to.equal(2)
|
||||
expect(didUpdateCount).to.equal(2)
|
||||
|
||||
setValue(2)
|
||||
|
||||
expect(willUpdateCount).to.equal(3)
|
||||
expect(didUpdateCount).to.equal(3)
|
||||
|
||||
setValue(1)
|
||||
|
||||
expect(willUpdateCount).to.equal(4)
|
||||
expect(didUpdateCount).to.equal(4)
|
||||
|
||||
Reconciler.unmount(instance)
|
||||
end)
|
||||
|
||||
it("should call getDerivedStateFromProps appropriately", function()
|
||||
local TestComponent = Component:extend("TestComponent")
|
||||
local getStateCallback
|
||||
|
||||
function TestComponent.getDerivedStateFromProps(newProps, oldState)
|
||||
return {
|
||||
visible = newProps.visible
|
||||
}
|
||||
end
|
||||
|
||||
function TestComponent:init(props)
|
||||
self.state = {
|
||||
visible = false
|
||||
}
|
||||
|
||||
getStateCallback = function()
|
||||
return self.state
|
||||
end
|
||||
end
|
||||
|
||||
function TestComponent:render() end
|
||||
|
||||
local handle = Reconciler.mount(createElement(TestComponent, {
|
||||
visible = true
|
||||
}))
|
||||
|
||||
local state = getStateCallback()
|
||||
expect(state.visible).to.equal(true)
|
||||
|
||||
handle = Reconciler.reconcile(handle, createElement(TestComponent, {
|
||||
visible = 123
|
||||
}))
|
||||
|
||||
state = getStateCallback()
|
||||
expect(state.visible).to.equal(123)
|
||||
|
||||
Reconciler.unmount(handle)
|
||||
end)
|
||||
|
||||
it("should pull values from defaultProps where appropriate", function()
|
||||
local lastProps
|
||||
local TestComponent = Component:extend("TestComponent")
|
||||
|
||||
TestComponent.defaultProps = {
|
||||
foo = "hello",
|
||||
bar = "world",
|
||||
}
|
||||
|
||||
function TestComponent:render()
|
||||
lastProps = self.props
|
||||
return nil
|
||||
end
|
||||
|
||||
local handle = Reconciler.mount(createElement(TestComponent))
|
||||
|
||||
expect(lastProps).to.be.a("table")
|
||||
expect(lastProps.foo).to.equal("hello")
|
||||
expect(lastProps.bar).to.equal("world")
|
||||
|
||||
Reconciler.unmount(handle)
|
||||
|
||||
lastProps = nil
|
||||
handle = Reconciler.mount(createElement(TestComponent, {
|
||||
foo = 5,
|
||||
}))
|
||||
|
||||
expect(lastProps).to.be.a("table")
|
||||
expect(lastProps.foo).to.equal(5)
|
||||
expect(lastProps.bar).to.equal("world")
|
||||
|
||||
Reconciler.unmount(handle)
|
||||
|
||||
lastProps = nil
|
||||
handle = Reconciler.mount(createElement(TestComponent, {
|
||||
bar = false,
|
||||
}))
|
||||
|
||||
expect(lastProps).to.be.a("table")
|
||||
expect(lastProps.foo).to.equal("hello")
|
||||
expect(lastProps.bar).to.equal(false)
|
||||
|
||||
Reconciler.unmount(handle)
|
||||
end)
|
||||
|
||||
it("should fall back to defaultProps correctly after an update", function()
|
||||
local lastProps
|
||||
local TestComponent = Component:extend("TestComponent")
|
||||
|
||||
TestComponent.defaultProps = {
|
||||
foo = "hello",
|
||||
bar = "world",
|
||||
}
|
||||
|
||||
function TestComponent:render()
|
||||
lastProps = self.props
|
||||
return nil
|
||||
end
|
||||
|
||||
local handle = Reconciler.mount(createElement(TestComponent, {
|
||||
foo = "hey"
|
||||
}))
|
||||
|
||||
expect(lastProps).to.be.a("table")
|
||||
expect(lastProps.foo).to.equal("hey")
|
||||
expect(lastProps.bar).to.equal("world")
|
||||
|
||||
handle = Reconciler.reconcile(handle, createElement(TestComponent))
|
||||
|
||||
expect(lastProps).to.be.a("table")
|
||||
expect(lastProps.foo).to.equal("hello")
|
||||
expect(lastProps.bar).to.equal("world")
|
||||
|
||||
Reconciler.unmount(handle)
|
||||
end)
|
||||
|
||||
describe("setState", function()
|
||||
it("should throw when called in init", function()
|
||||
local InitComponent = Component:extend("InitComponent")
|
||||
|
||||
function InitComponent:init()
|
||||
self:setState({
|
||||
a = 1
|
||||
})
|
||||
end
|
||||
|
||||
function InitComponent:render()
|
||||
return nil
|
||||
end
|
||||
|
||||
local initElement = createElement(InitComponent)
|
||||
|
||||
expect(function()
|
||||
Reconciler.mount(initElement)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw when called in render", function()
|
||||
local RenderComponent = Component:extend("RenderComponent")
|
||||
|
||||
function RenderComponent:render()
|
||||
self:setState({
|
||||
a = 1
|
||||
})
|
||||
end
|
||||
|
||||
local renderElement = createElement(RenderComponent)
|
||||
|
||||
expect(function()
|
||||
Reconciler.mount(renderElement)
|
||||
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 = createElement(TestComponent)
|
||||
|
||||
expect(function()
|
||||
Reconciler.mount(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 = createElement(TestComponent)
|
||||
|
||||
expect(function()
|
||||
Reconciler.mount(testElement)
|
||||
forceUpdate()
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw when called in willUnmount", function()
|
||||
local TestComponent = Component:extend("TestComponent")
|
||||
|
||||
function TestComponent:render()
|
||||
return nil
|
||||
end
|
||||
|
||||
function TestComponent:willUnmount()
|
||||
self:setState({
|
||||
a = 1
|
||||
})
|
||||
end
|
||||
|
||||
local element = createElement(TestComponent)
|
||||
local instance = Reconciler.mount(element)
|
||||
|
||||
expect(function()
|
||||
Reconciler.unmount(instance)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should remove values from state when the value is Core.None", function()
|
||||
local TestComponent = Component:extend("TestComponent")
|
||||
local setStateCallback, getStateCallback
|
||||
|
||||
function TestComponent:init()
|
||||
setStateCallback = function(newState)
|
||||
self:setState(newState)
|
||||
end
|
||||
|
||||
getStateCallback = function()
|
||||
return self.state
|
||||
end
|
||||
|
||||
self.state = {
|
||||
value = 0
|
||||
}
|
||||
end
|
||||
|
||||
function TestComponent:render()
|
||||
return nil
|
||||
end
|
||||
|
||||
local element = createElement(TestComponent)
|
||||
local instance = Reconciler.mount(element)
|
||||
|
||||
expect(getStateCallback().value).to.equal(0)
|
||||
|
||||
setStateCallback({
|
||||
value = Core.None
|
||||
})
|
||||
|
||||
expect(getStateCallback().value).to.equal(nil)
|
||||
|
||||
Reconciler.unmount(instance)
|
||||
end)
|
||||
|
||||
it("should invoke functions to compute a partial state", function()
|
||||
local TestComponent = Component:extend("TestComponent")
|
||||
local setStateCallback, getStateCallback, getPropsCallback
|
||||
|
||||
function TestComponent:init()
|
||||
setStateCallback = function(newState)
|
||||
self:setState(newState)
|
||||
end
|
||||
|
||||
getStateCallback = function()
|
||||
return self.state
|
||||
end
|
||||
|
||||
getPropsCallback = function()
|
||||
return self.props
|
||||
end
|
||||
|
||||
self.state = {
|
||||
value = 0
|
||||
}
|
||||
end
|
||||
|
||||
function TestComponent:render()
|
||||
return nil
|
||||
end
|
||||
|
||||
local element = createElement(TestComponent)
|
||||
local instance = Reconciler.mount(element)
|
||||
|
||||
expect(getStateCallback().value).to.equal(0)
|
||||
|
||||
setStateCallback(function(state, props)
|
||||
expect(state).to.equal(getStateCallback())
|
||||
expect(props).to.equal(getPropsCallback())
|
||||
|
||||
return {
|
||||
value = state.value + 1
|
||||
}
|
||||
end)
|
||||
|
||||
expect(getStateCallback().value).to.equal(1)
|
||||
|
||||
Reconciler.unmount(instance)
|
||||
end)
|
||||
|
||||
it("should cancel rendering if the function returns nil", function()
|
||||
local TestComponent = Component:extend("TestComponent")
|
||||
local setStateCallback
|
||||
local renderCount = 0
|
||||
|
||||
function TestComponent:init()
|
||||
setStateCallback = function(newState)
|
||||
self:setState(newState)
|
||||
end
|
||||
|
||||
self.state = {
|
||||
value = 0
|
||||
}
|
||||
end
|
||||
|
||||
function TestComponent:render()
|
||||
renderCount = renderCount + 1
|
||||
return nil
|
||||
end
|
||||
|
||||
local element = createElement(TestComponent)
|
||||
local instance = Reconciler.mount(element)
|
||||
expect(renderCount).to.equal(1)
|
||||
|
||||
setStateCallback(function(state, props)
|
||||
return nil
|
||||
end)
|
||||
|
||||
expect(renderCount).to.equal(1)
|
||||
|
||||
Reconciler.unmount(instance)
|
||||
end)
|
||||
|
||||
it("should not call getDerivedStateFromProps on setState", function()
|
||||
local TestComponent = Component:extend("TestComponent")
|
||||
local setStateCallback
|
||||
local getDerivedStateFromPropsCount = 0
|
||||
|
||||
function TestComponent:init()
|
||||
setStateCallback = function(newState)
|
||||
self:setState(newState)
|
||||
end
|
||||
|
||||
self.state = {
|
||||
value = 0
|
||||
}
|
||||
end
|
||||
|
||||
function TestComponent:render()
|
||||
return nil
|
||||
end
|
||||
|
||||
function TestComponent.getDerivedStateFromProps(nextProps, lastState)
|
||||
getDerivedStateFromPropsCount = getDerivedStateFromPropsCount + 1
|
||||
end
|
||||
|
||||
local element = createElement(TestComponent, {
|
||||
someProp = 1,
|
||||
})
|
||||
|
||||
local instance = Reconciler.mount(element)
|
||||
expect(getDerivedStateFromPropsCount).to.equal(1)
|
||||
|
||||
setStateCallback({
|
||||
value = 1,
|
||||
})
|
||||
expect(getDerivedStateFromPropsCount).to.equal(1)
|
||||
|
||||
|
||||
Reconciler.unmount(instance)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("getElementTraceback", function()
|
||||
it("should return stack traces", function()
|
||||
local stackTraceCallback = nil
|
||||
|
||||
GlobalConfig.set({
|
||||
elementTracing = true
|
||||
})
|
||||
|
||||
local TestComponent = Component:extend("TestComponent")
|
||||
|
||||
function TestComponent:init()
|
||||
stackTraceCallback = function()
|
||||
return self:getElementTraceback()
|
||||
end
|
||||
end
|
||||
|
||||
function TestComponent:render()
|
||||
return createElement("StringValue")
|
||||
end
|
||||
|
||||
local handle = Reconciler.mount(createElement(TestComponent))
|
||||
expect(stackTraceCallback()).to.be.ok()
|
||||
Reconciler.unmount(handle)
|
||||
GlobalConfig.reset()
|
||||
end)
|
||||
|
||||
it("should return nil when elementTracing is off", function()
|
||||
local stackTraceCallback = nil
|
||||
|
||||
local TestComponent = Component:extend("TestComponent")
|
||||
|
||||
function TestComponent:init()
|
||||
stackTraceCallback = function()
|
||||
return self:getElementTraceback()
|
||||
end
|
||||
end
|
||||
|
||||
function TestComponent:render()
|
||||
return createElement("StringValue")
|
||||
end
|
||||
|
||||
local handle = Reconciler.mount(createElement(TestComponent))
|
||||
expect(stackTraceCallback()).to.never.be.ok()
|
||||
Reconciler.unmount(handle)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,146 @@
|
||||
--[[
|
||||
Exposes an interface to set global configuration values for Roact.
|
||||
|
||||
Configuration can only occur once, and should only be done by an application
|
||||
using Roact, not a library.
|
||||
|
||||
Any keys that aren't recognized will cause errors. Configuration is only
|
||||
intended for configuring Roact itself, not extensions or libraries.
|
||||
|
||||
Configuration is expected to be set immediately after loading Roact. Setting
|
||||
configuration values after an application starts may produce unpredictable
|
||||
behavior.
|
||||
]]
|
||||
|
||||
-- Every valid configuration value should be non-nil in this table.
|
||||
local defaultConfig = {
|
||||
-- Enables storage of `debug.traceback()` values on elements for debugging.
|
||||
["elementTracing"] = false,
|
||||
-- Enables instrumentation of shouldUpdate and render methods for Roact components
|
||||
["componentInstrumentation"] = false,
|
||||
}
|
||||
|
||||
-- Build a list of valid configuration values up for debug messages.
|
||||
local defaultConfigKeys = {}
|
||||
for key in pairs(defaultConfig) do
|
||||
table.insert(defaultConfigKeys, key)
|
||||
end
|
||||
|
||||
--[[
|
||||
Merges two tables together into a new table.
|
||||
]]
|
||||
local function join(a, b)
|
||||
local new = {}
|
||||
|
||||
for key, value in pairs(a) do
|
||||
new[key] = value
|
||||
end
|
||||
|
||||
for key, value in pairs(b) do
|
||||
new[key] = value
|
||||
end
|
||||
|
||||
return new
|
||||
end
|
||||
|
||||
local Config = {}
|
||||
|
||||
function Config.new()
|
||||
local self = {}
|
||||
|
||||
-- Once configuration has been set, we record a traceback.
|
||||
-- That way, if the user mistakenly calls `set` twice, we can point to the
|
||||
-- first place it was called.
|
||||
self._lastConfigTraceback = nil
|
||||
|
||||
self._currentConfig = defaultConfig
|
||||
|
||||
-- We manually bind these methods here so that the Config's methods can be
|
||||
-- used without passing in self, since they eventually get exposed on the
|
||||
-- root Roact object.
|
||||
self.set = function(...)
|
||||
return Config.set(self, ...)
|
||||
end
|
||||
|
||||
self.getValue = function(...)
|
||||
return Config.getValue(self, ...)
|
||||
end
|
||||
|
||||
self.reset = function(...)
|
||||
return Config.reset(self, ...)
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
function Config.set(self, configValues)
|
||||
if self._lastConfigTraceback then
|
||||
local message = (
|
||||
"Global configuration can only be set once. Configuration was already set at:%s"
|
||||
):format(
|
||||
self._lastConfigTraceback
|
||||
)
|
||||
|
||||
error(message, 3)
|
||||
end
|
||||
|
||||
-- We use 3 as our traceback and error level because all of the methods are
|
||||
-- manually bound to 'self', which creates an additional stack frame we want
|
||||
-- to skip through.
|
||||
self._lastConfigTraceback = debug.traceback("", 3)
|
||||
|
||||
-- Validate values without changing any configuration.
|
||||
-- We only want to apply this configuration if it's valid!
|
||||
for key, value in pairs(configValues) do
|
||||
if defaultConfig[key] == nil then
|
||||
local message = (
|
||||
"Invalid global configuration key %q (type %s). Valid configuration keys are: %s"
|
||||
):format(
|
||||
tostring(key),
|
||||
typeof(key),
|
||||
table.concat(defaultConfigKeys, ", ")
|
||||
)
|
||||
|
||||
error(message, 3)
|
||||
end
|
||||
|
||||
-- Right now, all configuration values must be boolean.
|
||||
if typeof(value) ~= "boolean" then
|
||||
local message = (
|
||||
"Invalid value %q (type %s) for global configuration key %q. Valid values are: true, false"
|
||||
):format(
|
||||
tostring(value),
|
||||
typeof(value),
|
||||
tostring(key)
|
||||
)
|
||||
|
||||
error(message, 3)
|
||||
end
|
||||
end
|
||||
|
||||
-- Assign all of the (validated) configuration values in one go.
|
||||
self._currentConfig = join(self._currentConfig, configValues)
|
||||
end
|
||||
|
||||
function Config.getValue(self, key)
|
||||
if defaultConfig[key] == nil then
|
||||
local message = (
|
||||
"Invalid global configuration key %q (type %s). Valid configuration keys are: %s"
|
||||
):format(
|
||||
tostring(key),
|
||||
typeof(key),
|
||||
table.concat(defaultConfigKeys, ", ")
|
||||
)
|
||||
|
||||
error(message, 3)
|
||||
end
|
||||
|
||||
return self._currentConfig[key]
|
||||
end
|
||||
|
||||
function Config.reset(self)
|
||||
self._lastConfigTraceback = nil
|
||||
self._currentConfig = defaultConfig
|
||||
end
|
||||
|
||||
return Config
|
||||
@@ -0,0 +1,86 @@
|
||||
return function()
|
||||
local Config = require(script.Parent.Config)
|
||||
|
||||
it("should accept valid configuration", function()
|
||||
local config = Config.new()
|
||||
|
||||
expect(config.getValue("elementTracing")).to.equal(false)
|
||||
|
||||
config.set({
|
||||
elementTracing = true,
|
||||
})
|
||||
|
||||
expect(config.getValue("elementTracing")).to.equal(true)
|
||||
end)
|
||||
|
||||
it("should reject invalid configuration keys", function()
|
||||
local config = Config.new()
|
||||
|
||||
local badKey = "garblegoop"
|
||||
|
||||
local ok, err = pcall(function()
|
||||
config.set({
|
||||
[badKey] = true,
|
||||
})
|
||||
end)
|
||||
|
||||
expect(ok).to.equal(false)
|
||||
|
||||
-- The error should mention our bad key somewhere.
|
||||
expect(err:find(badKey)).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should reject invalid configuration values", function()
|
||||
local config = Config.new()
|
||||
|
||||
local goodKey = "elementTracing"
|
||||
local badValue = "Hello there!"
|
||||
|
||||
local ok, err = pcall(function()
|
||||
config.set({
|
||||
[goodKey] = badValue,
|
||||
})
|
||||
end)
|
||||
|
||||
expect(ok).to.equal(false)
|
||||
|
||||
-- The error should mention both our key and value
|
||||
expect(err:find(goodKey)).to.be.ok()
|
||||
expect(err:find(badValue)).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should prevent setting configuration more than once", function()
|
||||
local config = Config.new()
|
||||
|
||||
-- We're going to use the name of this function to see if the traceback
|
||||
-- was correct.
|
||||
local function setEmptyConfig()
|
||||
config.set({})
|
||||
end
|
||||
|
||||
setEmptyConfig()
|
||||
|
||||
local ok, err = pcall(setEmptyConfig)
|
||||
|
||||
expect(ok).to.equal(false)
|
||||
|
||||
-- The error should mention the stack trace with the original set call.
|
||||
expect(err:find("setEmptyConfig")).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should reset to default values after invoking reset()", function()
|
||||
local config = Config.new()
|
||||
|
||||
expect(config.getValue("elementTracing")).to.equal(false)
|
||||
|
||||
config.set({
|
||||
elementTracing = true,
|
||||
})
|
||||
|
||||
expect(config.getValue("elementTracing")).to.equal(true)
|
||||
|
||||
config.reset()
|
||||
|
||||
expect(config.getValue("elementTracing")).to.equal(false)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
--[[
|
||||
Provides a set of markers used for annotating data in Roact.
|
||||
]]
|
||||
|
||||
local Symbol = require(script.Parent.Symbol)
|
||||
|
||||
local Core = {}
|
||||
|
||||
-- Marker used to specify children of a node.
|
||||
Core.Children = Symbol.named("Children")
|
||||
|
||||
-- Marker used to specify a callback to receive the underlying Roblox object.
|
||||
Core.Ref = Symbol.named("Ref")
|
||||
|
||||
-- Marker used to specify that a component is a Roact Portal.
|
||||
Core.Portal = Symbol.named("Portal")
|
||||
|
||||
-- Marker used to specify that the value is nothing, because nil cannot be stored in tables.
|
||||
Core.None = Symbol.named("None")
|
||||
|
||||
-- Marker used to specify that the table it is present within is a component.
|
||||
Core.Element = Symbol.named("Element")
|
||||
|
||||
return Core
|
||||
@@ -0,0 +1,39 @@
|
||||
--[[
|
||||
Index into 'Event' to get a prop key for attaching to an event on a
|
||||
Roblox Instance.
|
||||
|
||||
Example:
|
||||
|
||||
Roact.createElement("TextButton", {
|
||||
Text = "Hello, world!",
|
||||
|
||||
[Roact.Event.MouseButton1Click] = function(rbx)
|
||||
print("Clicked", rbx)
|
||||
end
|
||||
})
|
||||
]]
|
||||
|
||||
local Event = {}
|
||||
|
||||
local eventMetatable = {
|
||||
__tostring = function(self)
|
||||
return ("Event(%s)"):format(self.name)
|
||||
end
|
||||
}
|
||||
|
||||
setmetatable(Event, {
|
||||
__index = function(self, eventName)
|
||||
local event = {
|
||||
type = Event,
|
||||
name = eventName
|
||||
}
|
||||
|
||||
setmetatable(event, eventMetatable)
|
||||
|
||||
Event[eventName] = event
|
||||
|
||||
return event
|
||||
end
|
||||
})
|
||||
|
||||
return Event
|
||||
@@ -0,0 +1,15 @@
|
||||
return function()
|
||||
local Event = require(script.Parent.Event)
|
||||
|
||||
it("should yield event objects when indexed", function()
|
||||
expect(Event.MouseButton1Click).to.be.ok()
|
||||
expect(Event.Touched).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should yield the same object when indexed again", function()
|
||||
local a = Event.MouseButton1Click
|
||||
local b = Event.MouseButton1Click
|
||||
|
||||
expect(a).to.equal(b)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
--[[
|
||||
Exposes a single instance of a configuration as Roact's GlobalConfig.
|
||||
]]
|
||||
|
||||
local Config = require(script.Parent.Config)
|
||||
|
||||
return Config.new()
|
||||
@@ -0,0 +1,10 @@
|
||||
return function()
|
||||
local GlobalConfig = require(script.Parent.GlobalConfig)
|
||||
|
||||
it("should have the correct methods", function()
|
||||
expect(GlobalConfig).to.be.ok()
|
||||
expect(GlobalConfig.set).to.be.ok()
|
||||
expect(GlobalConfig.getValue).to.be.ok()
|
||||
expect(GlobalConfig.reset).to.be.ok()
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
--[[
|
||||
An optional instrumentation layer that the reconciler calls into to record
|
||||
various events.
|
||||
|
||||
Tracks a number of stats, including:
|
||||
Recorded stats:
|
||||
- Render count by component
|
||||
- Update request count by component
|
||||
- Actual update count by component
|
||||
- shouldUpdate returned true count by component
|
||||
- Time taken to run shouldUpdate
|
||||
- Time taken to render by component
|
||||
Derivable stats (for profiling manually or with a future tool):
|
||||
- Average render time by component
|
||||
- Percent of total render time by component
|
||||
- Percent of time shouldUpdate returns true
|
||||
- Average shouldUpdate time by component
|
||||
- Percent of total shouldUpdate time by component
|
||||
]]
|
||||
|
||||
local Instrumentation = {}
|
||||
|
||||
local componentStats = {}
|
||||
|
||||
--[[
|
||||
Determines name of component from the given instance handle and returns a
|
||||
stat object from the componentStats table, generating a new one if needed
|
||||
]]
|
||||
local function getStatEntry(handle)
|
||||
local name
|
||||
if handle and handle._element and handle._element.component then
|
||||
name = tostring(handle._element.component)
|
||||
else
|
||||
warn("Component name not valid for " .. tostring(handle._key))
|
||||
return nil
|
||||
end
|
||||
local entry = componentStats[name]
|
||||
if not entry then
|
||||
entry = {
|
||||
-- update requests
|
||||
updateReqCount = 0,
|
||||
-- actual updates
|
||||
didUpdateCount = 0,
|
||||
-- time spent in shouldUpdate
|
||||
shouldUpdateTime = 0,
|
||||
-- number of renders
|
||||
renderCount = 0,
|
||||
-- total render time spent
|
||||
renderTime = 0,
|
||||
}
|
||||
componentStats[name] = entry
|
||||
end
|
||||
|
||||
return entry
|
||||
end
|
||||
|
||||
--[[
|
||||
Logs the time taken and resulting value of a Component's shouldUpdate function
|
||||
]]
|
||||
function Instrumentation.logShouldUpdate(handle, updateNeeded, shouldUpdateTime)
|
||||
-- Grab or create associated entry in stats table
|
||||
local statEntry = getStatEntry(handle)
|
||||
if statEntry then
|
||||
-- Increment the total number of times update was invoked
|
||||
statEntry.updateReqCount = statEntry.updateReqCount + 1
|
||||
|
||||
-- Increment (when applicable) total number of times shouldUpdate returned true
|
||||
statEntry.didUpdateCount = statEntry.didUpdateCount + (updateNeeded and 1 or 0)
|
||||
|
||||
-- Add time spent checking if an update is needed (in millis) to total time
|
||||
statEntry.shouldUpdateTime = statEntry.shouldUpdateTime + shouldUpdateTime * 1000
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Logs the time taken value of a Component's render function
|
||||
]]
|
||||
function Instrumentation.logRenderTime(handle, renderTime)
|
||||
-- Grab or create associated entry in stats table
|
||||
local statEntry = getStatEntry(handle)
|
||||
if statEntry then
|
||||
-- Increment total render count
|
||||
statEntry.renderCount = statEntry.renderCount + 1
|
||||
|
||||
-- Add render time (in millis) to total rendering time
|
||||
statEntry.renderTime = statEntry.renderTime + renderTime * 1000
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Clears all the stats collected thus far. Useful for testing and for profiling in the future
|
||||
]]
|
||||
function Instrumentation.clearCollectedStats()
|
||||
componentStats = {}
|
||||
end
|
||||
|
||||
--[[
|
||||
Returns all the stats collected thus far. Useful for testing and for profiling in the future
|
||||
]]
|
||||
function Instrumentation.getCollectedStats()
|
||||
return componentStats
|
||||
end
|
||||
|
||||
return Instrumentation
|
||||
@@ -0,0 +1,98 @@
|
||||
return function()
|
||||
local Component = require(script.Parent.PureComponent)
|
||||
local GlobalConfig = require(script.Parent.GlobalConfig)
|
||||
local Reconciler = require(script.Parent.Reconciler)
|
||||
local createElement = require(script.Parent.createElement)
|
||||
|
||||
local Instrumentation = require(script.Parent.Instrumentation)
|
||||
|
||||
it("should count and time renders when enabled", function()
|
||||
GlobalConfig.set({
|
||||
["componentInstrumentation"] = true,
|
||||
})
|
||||
local triggerUpdate
|
||||
|
||||
local TestComponent = Component:extend("TestComponent")
|
||||
function TestComponent:init()
|
||||
self.state = {
|
||||
value = 0
|
||||
}
|
||||
end
|
||||
|
||||
function TestComponent:render()
|
||||
return nil
|
||||
end
|
||||
|
||||
function TestComponent:didMount()
|
||||
triggerUpdate = function()
|
||||
self:setState({
|
||||
value = self.state.value + 1
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local instance = Reconciler.mount(createElement(TestComponent))
|
||||
|
||||
local stats = Instrumentation.getCollectedStats()
|
||||
expect(stats.TestComponent).to.be.ok()
|
||||
expect(stats.TestComponent.renderCount).to.equal(1)
|
||||
|
||||
triggerUpdate()
|
||||
expect(stats.TestComponent.renderCount).to.equal(2)
|
||||
|
||||
Reconciler.unmount(instance)
|
||||
Instrumentation.clearCollectedStats()
|
||||
GlobalConfig.reset()
|
||||
end)
|
||||
|
||||
it("should count and time shouldUpdate calls when enabled", function()
|
||||
GlobalConfig.set({
|
||||
["componentInstrumentation"] = true,
|
||||
})
|
||||
local triggerUpdate
|
||||
local willDoUpdate = false
|
||||
|
||||
local TestComponent = Component:extend("TestComponent")
|
||||
|
||||
function TestComponent:init()
|
||||
self.state = {
|
||||
value = 0,
|
||||
}
|
||||
end
|
||||
|
||||
function TestComponent:shouldUpdate()
|
||||
return willDoUpdate
|
||||
end
|
||||
|
||||
function TestComponent:didMount()
|
||||
triggerUpdate = function()
|
||||
self:setState({
|
||||
value = self.state.value + 1,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function TestComponent:render() end
|
||||
|
||||
local instance = Reconciler.mount(createElement(TestComponent))
|
||||
|
||||
local stats = Instrumentation.getCollectedStats()
|
||||
|
||||
willDoUpdate = true
|
||||
triggerUpdate()
|
||||
|
||||
expect(stats.TestComponent).to.be.ok()
|
||||
expect(stats.TestComponent.updateReqCount).to.equal(1)
|
||||
expect(stats.TestComponent.didUpdateCount).to.equal(1)
|
||||
|
||||
willDoUpdate = false
|
||||
triggerUpdate()
|
||||
|
||||
expect(stats.TestComponent.updateReqCount).to.equal(2)
|
||||
expect(stats.TestComponent.didUpdateCount).to.equal(1)
|
||||
|
||||
Reconciler.unmount(instance)
|
||||
Instrumentation.clearCollectedStats()
|
||||
GlobalConfig.reset()
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
--[[
|
||||
A version of Component with a `shouldUpdate` method that forces the
|
||||
resulting component to be pure.
|
||||
]]
|
||||
|
||||
local Component = require(script.Parent.Component)
|
||||
|
||||
local PureComponent = Component:extend("PureComponent")
|
||||
|
||||
-- When extend()ing a component, you don't get an extend method.
|
||||
-- This is to promote composition over inheritance.
|
||||
-- PureComponent is an exception to this rule.
|
||||
PureComponent.extend = Component.extend
|
||||
|
||||
function PureComponent:shouldUpdate(newProps, newState)
|
||||
-- In a vast majority of cases, if state updated, something has updated.
|
||||
-- We don't bother checking in this case.
|
||||
if newState ~= self.state then
|
||||
return true
|
||||
end
|
||||
|
||||
if newProps == self.props then
|
||||
return false
|
||||
end
|
||||
|
||||
for key, value in pairs(newProps) do
|
||||
if self.props[key] ~= value then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
for key, value in pairs(self.props) do
|
||||
if newProps[key] ~= value then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
return PureComponent
|
||||
@@ -0,0 +1,71 @@
|
||||
return function()
|
||||
local createElement = require(script.Parent.createElement)
|
||||
local Reconciler = require(script.Parent.Reconciler)
|
||||
|
||||
local PureComponent = require(script.Parent.PureComponent)
|
||||
|
||||
it("should be extendable", function()
|
||||
local MyComponent = PureComponent:extend("MyComponent")
|
||||
|
||||
expect(MyComponent).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should skip updates for shallow-equal props", function()
|
||||
local updateCount = 0
|
||||
local setValue
|
||||
|
||||
local PureChild = PureComponent:extend("PureChild")
|
||||
|
||||
function PureChild:willUpdate(newProps, newState)
|
||||
updateCount = updateCount + 1
|
||||
end
|
||||
|
||||
function PureChild:render()
|
||||
end
|
||||
|
||||
local PureContainer = PureComponent:extend("PureContainer")
|
||||
|
||||
function PureContainer:init()
|
||||
self.state = {
|
||||
value = 0,
|
||||
}
|
||||
end
|
||||
|
||||
function PureContainer:didMount()
|
||||
setValue = function(value)
|
||||
self:setState({
|
||||
value = value,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function PureContainer:render()
|
||||
return createElement(PureChild, {
|
||||
value = self.state.value,
|
||||
})
|
||||
end
|
||||
|
||||
local element = createElement(PureContainer)
|
||||
local instance = Reconciler.mount(element)
|
||||
|
||||
expect(updateCount).to.equal(0)
|
||||
|
||||
setValue(1)
|
||||
|
||||
expect(updateCount).to.equal(1)
|
||||
|
||||
setValue(1)
|
||||
|
||||
expect(updateCount).to.equal(1)
|
||||
|
||||
setValue(2)
|
||||
|
||||
expect(updateCount).to.equal(2)
|
||||
|
||||
setValue(1)
|
||||
|
||||
expect(updateCount).to.equal(3)
|
||||
|
||||
Reconciler.unmount(instance)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,543 @@
|
||||
--[[
|
||||
The reconciler uses the virtual DOM generated by components to create a real
|
||||
tree of Roblox instances.
|
||||
|
||||
The reonciler has three basic operations:
|
||||
* mount (previously reify)
|
||||
* reconcile
|
||||
* unmount (previously teardown)
|
||||
|
||||
Mounting is the process of creating new components. This is first
|
||||
triggered when the user calls `Roact.mount` on an element. This is where the
|
||||
structure of the component tree is built, later used and modified by the
|
||||
reconciliation and unmounting steps.
|
||||
|
||||
Reconciliation accepts an existing concrete instance tree (created by mount)
|
||||
along with a new element that describes the desired tree. The reconciler
|
||||
will do the minimum amount of work required to update tree's components to
|
||||
match the new element, sometimes invoking mount to create new branches.
|
||||
|
||||
Unmounting destructs for the tree. It will crawl through the tree,
|
||||
destroying nodes from the bottom up.
|
||||
|
||||
Much of the reconciler's work is done by Component, which is the base for
|
||||
all stateful components in Roact. Components can trigger reconciliation (and
|
||||
implicitly, unmounting) via state updates that come with their own caveats.
|
||||
]]
|
||||
|
||||
local Core = require(script.Parent.Core)
|
||||
local Event = require(script.Parent.Event)
|
||||
local Change = require(script.Parent.Change)
|
||||
local getDefaultPropertyValue = require(script.Parent.getDefaultPropertyValue)
|
||||
local SingleEventManager = require(script.Parent.SingleEventManager)
|
||||
local Symbol = require(script.Parent.Symbol)
|
||||
|
||||
local isInstanceHandle = Symbol.named("isInstanceHandle")
|
||||
|
||||
local DEFAULT_SOURCE = "\n\t<Use Roact.setGlobalConfig with the 'elementTracing' key to enable detailed tracebacks>\n"
|
||||
|
||||
local function isPortal(element)
|
||||
if type(element) ~= "table" then
|
||||
return false
|
||||
end
|
||||
|
||||
return element.component == Core.Portal
|
||||
end
|
||||
|
||||
--[[
|
||||
Sets the value of a reference to a new rendered object.
|
||||
Correctly handles both function-style and object-style refs.
|
||||
]]
|
||||
local function applyRef(ref, newRbx)
|
||||
if ref == nil then
|
||||
return
|
||||
end
|
||||
|
||||
if type(ref) == "table" then
|
||||
ref.current = newRbx
|
||||
else
|
||||
ref(newRbx)
|
||||
end
|
||||
end
|
||||
|
||||
local Reconciler = {}
|
||||
|
||||
Reconciler._singleEventManager = SingleEventManager.new()
|
||||
|
||||
--[[
|
||||
Is this element backed by a Roblox instance directly?
|
||||
]]
|
||||
local function isPrimitiveElement(element)
|
||||
if type(element) ~= "table" then
|
||||
return false
|
||||
end
|
||||
|
||||
return type(element.component) == "string"
|
||||
end
|
||||
|
||||
--[[
|
||||
Is this element defined by a pure function?
|
||||
]]
|
||||
local function isFunctionalElement(element)
|
||||
if type(element) ~= "table" then
|
||||
return false
|
||||
end
|
||||
|
||||
return type(element.component) == "function"
|
||||
end
|
||||
|
||||
--[[
|
||||
Is this element defined by a component class?
|
||||
]]
|
||||
local function isStatefulElement(element)
|
||||
if type(element) ~= "table" then
|
||||
return false
|
||||
end
|
||||
|
||||
return type(element.component) == "table"
|
||||
end
|
||||
|
||||
--[[
|
||||
Destroy the given Roact instance, all of its descendants, and associated
|
||||
Roblox instances owned by the components.
|
||||
]]
|
||||
function Reconciler.unmount(instanceHandle)
|
||||
local element = instanceHandle._element
|
||||
|
||||
if isPrimitiveElement(element) then
|
||||
-- We're destroying a Roblox Instance-based object
|
||||
|
||||
-- Kill refs before we make changes, since any mutations past this point
|
||||
-- aren't relevant to components.
|
||||
applyRef(element.props[Core.Ref], nil)
|
||||
|
||||
for _, child in pairs(instanceHandle._children) do
|
||||
Reconciler.unmount(child)
|
||||
end
|
||||
|
||||
-- Necessary to make sure SingleEventManager doesn't leak references
|
||||
Reconciler._singleEventManager:disconnectAll(instanceHandle._rbx)
|
||||
|
||||
instanceHandle._rbx:Destroy()
|
||||
elseif isFunctionalElement(element) then
|
||||
-- Functional components can return nil
|
||||
if instanceHandle._child then
|
||||
Reconciler.unmount(instanceHandle._child)
|
||||
end
|
||||
elseif isStatefulElement(element) then
|
||||
instanceHandle._instance:_unmount()
|
||||
elseif isPortal(element) then
|
||||
for _, child in pairs(instanceHandle._children) do
|
||||
Reconciler.unmount(child)
|
||||
end
|
||||
else
|
||||
error(("Cannot unmount invalid Roact instance %q"):format(tostring(element)))
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Public interface to reifier. Hides parameters used when recursing down the
|
||||
component tree.
|
||||
]]
|
||||
function Reconciler.mount(element, parent, key)
|
||||
return Reconciler._mountInternal(element, parent, key)
|
||||
end
|
||||
|
||||
--[[
|
||||
Instantiates components to represent the given element.
|
||||
|
||||
Parameters:
|
||||
- `element`: The element to mount.
|
||||
- `parent`: The Roblox object to contain the contained instances
|
||||
- `key`: The Name to give the Roblox instance that gets created
|
||||
- `context`: Used to pass Roact context values down the tree
|
||||
|
||||
The structure created by this method is important to the functionality of
|
||||
the reconciliation methods; they depend on this structure being well-formed.
|
||||
]]
|
||||
function Reconciler._mountInternal(element, parent, key, context)
|
||||
if isPrimitiveElement(element) then
|
||||
-- Primitive elements are backed directly by Roblox Instances.
|
||||
|
||||
local rbx = Instance.new(element.component)
|
||||
|
||||
-- Update Roblox properties
|
||||
for key, value in pairs(element.props) do
|
||||
Reconciler._setRbxProp(rbx, key, value, element)
|
||||
end
|
||||
|
||||
-- Create children!
|
||||
local children = {}
|
||||
|
||||
if element.props[Core.Children] then
|
||||
for key, childElement in pairs(element.props[Core.Children]) do
|
||||
local childInstance = Reconciler._mountInternal(childElement, rbx, key, context)
|
||||
|
||||
children[key] = childInstance
|
||||
end
|
||||
end
|
||||
|
||||
-- This name can be passed through multiple components.
|
||||
-- Elements with the same key will be treated as the same
|
||||
-- element between reconciles; the old element will be
|
||||
-- reconciled to the new element with the same key.
|
||||
if key then
|
||||
rbx.Name = key
|
||||
end
|
||||
|
||||
rbx.Parent = parent
|
||||
|
||||
-- Attach ref values, since the instance is initialized now.
|
||||
applyRef(element.props[Core.Ref], rbx)
|
||||
|
||||
return {
|
||||
[isInstanceHandle] = true,
|
||||
_key = key,
|
||||
_parent = parent,
|
||||
_element = element,
|
||||
_context = context,
|
||||
_children = children,
|
||||
_rbx = rbx,
|
||||
}
|
||||
elseif isFunctionalElement(element) then
|
||||
-- Functional elements contain 0 or 1 children.
|
||||
|
||||
local instanceHandle = {
|
||||
[isInstanceHandle] = true,
|
||||
_key = key,
|
||||
_parent = parent,
|
||||
_element = element,
|
||||
_context = context,
|
||||
}
|
||||
|
||||
local vdom = element.component(element.props)
|
||||
if vdom then
|
||||
instanceHandle._child = Reconciler._mountInternal(vdom, parent, key, context)
|
||||
end
|
||||
|
||||
return instanceHandle
|
||||
elseif isStatefulElement(element) then
|
||||
-- Stateful elements have 0 or 1 children, and also have a backing
|
||||
-- instance that can keep state.
|
||||
|
||||
-- We separate the instance's implementation from our handle to it.
|
||||
local instanceHandle = {
|
||||
[isInstanceHandle] = true,
|
||||
_key = key,
|
||||
_parent = parent,
|
||||
_element = element,
|
||||
_child = nil,
|
||||
}
|
||||
|
||||
local instance = element.component._new(element.props, context)
|
||||
|
||||
instanceHandle._instance = instance
|
||||
instance:_mount(instanceHandle)
|
||||
|
||||
return instanceHandle
|
||||
elseif isPortal(element) then
|
||||
-- Portal elements have one or more children.
|
||||
|
||||
local target = element.props.target
|
||||
if not target then
|
||||
error(("Cannot mount Portal without specifying a target."):format(tostring(element)))
|
||||
elseif typeof(target) ~= "Instance" then
|
||||
error(("Cannot mount Portal with target of type %q."):format(typeof(target)))
|
||||
end
|
||||
|
||||
-- Create children!
|
||||
local children = {}
|
||||
|
||||
if element.props[Core.Children] then
|
||||
for key, childElement in pairs(element.props[Core.Children]) do
|
||||
local childInstance = Reconciler._mountInternal(childElement, target, key, context)
|
||||
|
||||
children[key] = childInstance
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
[isInstanceHandle] = true,
|
||||
_key = key,
|
||||
_parent = parent,
|
||||
_element = element,
|
||||
_context = context,
|
||||
_children = children,
|
||||
_rbx = target,
|
||||
}
|
||||
elseif typeof(element) == "boolean" then
|
||||
-- Ignore booleans of either value
|
||||
-- See https://github.com/Roblox/roact/issues/14
|
||||
return nil
|
||||
end
|
||||
|
||||
error(("Cannot mount invalid Roact element %q"):format(tostring(element)))
|
||||
end
|
||||
|
||||
--[[
|
||||
A public interface around _reconcileInternal
|
||||
]]
|
||||
function Reconciler.reconcile(instanceHandle, newElement)
|
||||
if instanceHandle == nil or not instanceHandle[isInstanceHandle] then
|
||||
local message = (
|
||||
"Bad argument #1 to Reconciler.reconcile, expected component instance handle, found %s"
|
||||
):format(
|
||||
typeof(instanceHandle)
|
||||
)
|
||||
|
||||
error(message, 2)
|
||||
end
|
||||
|
||||
return Reconciler._reconcileInternal(instanceHandle, newElement)
|
||||
end
|
||||
|
||||
--[[
|
||||
Applies the state given by newElement to an existing Roact instance.
|
||||
|
||||
reconcile will return the instance that should be used. This instance can
|
||||
be different than the one that was passed in.
|
||||
]]
|
||||
function Reconciler._reconcileInternal(instanceHandle, newElement)
|
||||
local oldElement = instanceHandle._element
|
||||
|
||||
-- Instance was deleted!
|
||||
if not newElement then
|
||||
Reconciler.unmount(instanceHandle)
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
-- If the element changes type, we assume its subtree will be substantially
|
||||
-- different. This lets us skip comparisons of a large swath of nodes.
|
||||
if oldElement.component ~= newElement.component then
|
||||
local parent = instanceHandle._parent
|
||||
local key = instanceHandle._key
|
||||
|
||||
local context
|
||||
if isStatefulElement(oldElement) then
|
||||
context = instanceHandle._instance._context
|
||||
else
|
||||
context = instanceHandle._context
|
||||
end
|
||||
|
||||
Reconciler.unmount(instanceHandle)
|
||||
|
||||
local newInstance = Reconciler._mountInternal(newElement, parent, key, context)
|
||||
|
||||
return newInstance
|
||||
end
|
||||
|
||||
if isPrimitiveElement(newElement) then
|
||||
-- Roblox Instance change
|
||||
|
||||
local oldRef = oldElement.props[Core.Ref]
|
||||
local newRef = newElement.props[Core.Ref]
|
||||
|
||||
-- Change the ref in one pass before applying any changes.
|
||||
-- Roact doesn't provide any guarantees with regards to the sequencing
|
||||
-- between refs and other changes in the commit phase.
|
||||
if newRef ~= oldRef then
|
||||
applyRef(oldRef, nil)
|
||||
applyRef(newRef, instanceHandle._rbx)
|
||||
end
|
||||
|
||||
-- Update properties and children of the Roblox object.
|
||||
Reconciler._reconcilePrimitiveProps(oldElement, newElement, instanceHandle._rbx)
|
||||
Reconciler._reconcilePrimitiveChildren(instanceHandle, newElement)
|
||||
|
||||
instanceHandle._element = newElement
|
||||
|
||||
return instanceHandle
|
||||
elseif isFunctionalElement(newElement) then
|
||||
instanceHandle._element = newElement
|
||||
|
||||
local rendered = newElement.component(newElement.props)
|
||||
local newChild
|
||||
|
||||
if instanceHandle._child then
|
||||
-- Transition from tree to tree, even if 'rendered' is nil
|
||||
newChild = Reconciler._reconcileInternal(instanceHandle._child, rendered)
|
||||
elseif rendered then
|
||||
-- Transition from nil to new tree
|
||||
newChild = Reconciler._mountInternal(
|
||||
rendered,
|
||||
instanceHandle._parent,
|
||||
instanceHandle._key,
|
||||
instanceHandle._context
|
||||
)
|
||||
end
|
||||
|
||||
instanceHandle._child = newChild
|
||||
|
||||
return instanceHandle
|
||||
elseif isStatefulElement(newElement) then
|
||||
instanceHandle._element = newElement
|
||||
|
||||
-- Stateful elements can take care of themselves.
|
||||
instanceHandle._instance:_update(newElement.props)
|
||||
|
||||
return instanceHandle
|
||||
elseif isPortal(newElement) then
|
||||
if instanceHandle._rbx ~= newElement.props.target then
|
||||
local parent = instanceHandle._parent
|
||||
local key = instanceHandle._key
|
||||
local context = instanceHandle._context
|
||||
|
||||
Reconciler.unmount(instanceHandle)
|
||||
|
||||
local newInstance = Reconciler._mountInternal(newElement, parent, key, context)
|
||||
|
||||
return newInstance
|
||||
end
|
||||
|
||||
Reconciler._reconcilePrimitiveChildren(instanceHandle, newElement)
|
||||
|
||||
instanceHandle._element = newElement
|
||||
|
||||
return instanceHandle
|
||||
end
|
||||
|
||||
error(("Cannot reconcile to match invalid Roact element %q"):format(tostring(newElement)))
|
||||
end
|
||||
|
||||
--[[
|
||||
Reconciles the children of an existing Roact instance and the given element.
|
||||
]]
|
||||
function Reconciler._reconcilePrimitiveChildren(instance, newElement)
|
||||
local elementChildren = newElement.props[Core.Children]
|
||||
|
||||
-- Reconcile existing children that were changed or removed
|
||||
for key, childInstance in pairs(instance._children) do
|
||||
local childElement = elementChildren and elementChildren[key]
|
||||
|
||||
childInstance = Reconciler._reconcileInternal(childInstance, childElement)
|
||||
|
||||
instance._children[key] = childInstance
|
||||
end
|
||||
|
||||
-- Create children that were just added!
|
||||
if elementChildren then
|
||||
for key, childElement in pairs(elementChildren) do
|
||||
-- Update if we didn't hit the child in the previous loop
|
||||
if not instance._children[key] then
|
||||
local childInstance = Reconciler._mountInternal(childElement, instance._rbx, key, instance._context)
|
||||
instance._children[key] = childInstance
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Reconciles the properties between two primitive Roact elements and applies
|
||||
the differences to the given Roblox object.
|
||||
]]
|
||||
function Reconciler._reconcilePrimitiveProps(fromElement, toElement, rbx)
|
||||
local seenProps = {}
|
||||
|
||||
-- Set properties that were set with fromElement
|
||||
for key, oldValue in pairs(fromElement.props) do
|
||||
seenProps[key] = true
|
||||
|
||||
local newValue = toElement.props[key]
|
||||
|
||||
-- Assume any property that can be set to nil has a default value of nil
|
||||
if newValue == nil then
|
||||
local _, value = getDefaultPropertyValue(rbx.ClassName, key)
|
||||
|
||||
-- We don't care if getDefaultPropertyValue fails, because
|
||||
-- _setRbxProp will catch the error below.
|
||||
newValue = value
|
||||
end
|
||||
|
||||
-- Roblox does this check for normal values, but we have special
|
||||
-- properties like events that warrant this.
|
||||
if oldValue ~= newValue then
|
||||
Reconciler._setRbxProp(rbx, key, newValue, toElement)
|
||||
end
|
||||
end
|
||||
|
||||
-- Set properties that are new in toElement
|
||||
for key, newValue in pairs(toElement.props) do
|
||||
if not seenProps[key] then
|
||||
seenProps[key] = true
|
||||
|
||||
local oldValue = fromElement.props[key]
|
||||
|
||||
if oldValue ~= newValue then
|
||||
Reconciler._setRbxProp(rbx, key, newValue, toElement)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Used in _setRbxProp to avoid creating a new closure for every property set.
|
||||
]]
|
||||
local function set(rbx, key, value)
|
||||
rbx[key] = value
|
||||
end
|
||||
|
||||
--[[
|
||||
Sets a property on a Roblox object, following Roact's rules for special
|
||||
case properties.
|
||||
|
||||
This function can throw a couple different errors. In the future, calls to
|
||||
_setRbxProp should be wrapped in a pcall to give better errors to the user.
|
||||
|
||||
For that to be useful, we'll need to attach a 'source' property on every
|
||||
element, created using debug.traceback(), that points to where the element
|
||||
was created.
|
||||
]]
|
||||
function Reconciler._setRbxProp(rbx, key, value, element)
|
||||
if type(key) == "string" then
|
||||
-- Regular property
|
||||
|
||||
local success, err = pcall(set, rbx, key, value)
|
||||
|
||||
if not success then
|
||||
local source = element.source or DEFAULT_SOURCE
|
||||
|
||||
local message = ("Failed to set property %s on primitive instance of class %s\n%s\n%s"):format(
|
||||
key,
|
||||
rbx.ClassName,
|
||||
err,
|
||||
source
|
||||
)
|
||||
|
||||
error(message, 0)
|
||||
end
|
||||
elseif type(key) == "table" then
|
||||
-- Special property with extra data attached.
|
||||
|
||||
if key.type == Event then
|
||||
Reconciler._singleEventManager:connect(rbx, key.name, value)
|
||||
elseif key.type == Change then
|
||||
Reconciler._singleEventManager:connectProperty(rbx, key.name, value)
|
||||
else
|
||||
local source = element.source or DEFAULT_SOURCE
|
||||
|
||||
-- luacheck: ignore 6
|
||||
local message = ("Failed to set special property on primitive instance of class %s\nInvalid special property type %q\n%s"):format(
|
||||
rbx.ClassName,
|
||||
tostring(key.type),
|
||||
source
|
||||
)
|
||||
|
||||
error(message, 0)
|
||||
end
|
||||
elseif type(key) ~= "userdata" then
|
||||
-- Userdata values are special markers, usually created by Symbol
|
||||
-- They have no data attached other than being unique keys
|
||||
|
||||
local source = element.source or DEFAULT_SOURCE
|
||||
|
||||
local message = ("Properties with a key type of %q are not supported\n%s"):format(
|
||||
type(key),
|
||||
source
|
||||
)
|
||||
|
||||
error(message, 0)
|
||||
end
|
||||
end
|
||||
|
||||
return Reconciler
|
||||
@@ -0,0 +1,88 @@
|
||||
return function()
|
||||
local Core = require(script.Parent.Core)
|
||||
local createRef = require(script.Parent.createRef)
|
||||
local createElement = require(script.Parent.createElement)
|
||||
|
||||
local Reconciler = require(script.Parent.Reconciler)
|
||||
|
||||
it("should mount booleans as nil", function()
|
||||
local booleanReified = Reconciler.mount(false)
|
||||
expect(booleanReified).to.never.be.ok()
|
||||
end)
|
||||
|
||||
it("should handle object references properly", function()
|
||||
local objectRef = createRef()
|
||||
local element = createElement("StringValue", {
|
||||
[Core.Ref] = objectRef,
|
||||
})
|
||||
|
||||
local handle = Reconciler.mount(element)
|
||||
expect(objectRef.current).to.be.ok()
|
||||
Reconciler.unmount(handle)
|
||||
expect(objectRef.current).to.never.be.ok()
|
||||
end)
|
||||
|
||||
it("should handle function references properly", function()
|
||||
local currentRbx
|
||||
|
||||
local function ref(rbx)
|
||||
currentRbx = rbx
|
||||
end
|
||||
|
||||
local element = createElement("StringValue", {
|
||||
[Core.Ref] = ref,
|
||||
})
|
||||
|
||||
local handle = Reconciler.mount(element)
|
||||
expect(currentRbx).to.be.ok()
|
||||
Reconciler.unmount(handle)
|
||||
expect(currentRbx).to.never.be.ok()
|
||||
end)
|
||||
|
||||
it("should handle changing function references", function()
|
||||
local aValue, bValue
|
||||
|
||||
local function aRef(rbx)
|
||||
aValue = rbx
|
||||
end
|
||||
|
||||
local function bRef(rbx)
|
||||
bValue = rbx
|
||||
end
|
||||
|
||||
local element = createElement("StringValue", {
|
||||
[Core.Ref] = aRef,
|
||||
})
|
||||
|
||||
local handle = Reconciler.mount(element, game, "Test123")
|
||||
expect(aValue).to.be.ok()
|
||||
expect(bValue).to.never.be.ok()
|
||||
handle = Reconciler.reconcile(handle, createElement("StringValue", {
|
||||
[Core.Ref] = bRef,
|
||||
}))
|
||||
expect(aValue).to.never.be.ok()
|
||||
expect(bValue).to.be.ok()
|
||||
Reconciler.unmount(handle)
|
||||
expect(bValue).to.never.be.ok()
|
||||
end)
|
||||
|
||||
it("should handle changing object references", function()
|
||||
local aRef = createRef()
|
||||
local bRef = createRef()
|
||||
|
||||
local element = createElement("StringValue", {
|
||||
[Core.Ref] = aRef,
|
||||
})
|
||||
|
||||
local handle = Reconciler.mount(element, game, "Test123")
|
||||
expect(aRef.current).to.be.ok()
|
||||
expect(bRef.current).to.never.be.ok()
|
||||
handle = Reconciler.reconcile(handle, createElement("StringValue", {
|
||||
[Core.Ref] = bRef,
|
||||
}))
|
||||
expect(aRef.current).to.never.be.ok()
|
||||
expect(bRef.current).to.be.ok()
|
||||
Reconciler.unmount(handle)
|
||||
expect(bRef.current).to.never.be.ok()
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
--[[
|
||||
Contains deprecated methods from Reconciler. Broken out so that removing
|
||||
this shim is easy -- just delete this file and remove it from init.
|
||||
]]
|
||||
|
||||
local Reconciler = require(script.Parent.Reconciler)
|
||||
|
||||
local warnedLocations = {}
|
||||
|
||||
local reifyMessage = [[
|
||||
Roact.reify has been renamed to Roact.mount and will be removed in a future release.
|
||||
Check the call to Roact.reify at:
|
||||
]]
|
||||
|
||||
local teardownMessage = [[
|
||||
Roact.teardown has been renamed to Roact.unmount and will be removed in a future release.
|
||||
Check the call to Roact.teardown at:
|
||||
]]
|
||||
|
||||
local ReconcilerCompat = {}
|
||||
|
||||
--[[
|
||||
Exposed as a method so that test cases can override `warn`.
|
||||
]]
|
||||
ReconcilerCompat._warn = warn
|
||||
|
||||
local function warnOnce(message)
|
||||
local trace = debug.traceback(message, 3)
|
||||
if warnedLocations[trace] then
|
||||
return
|
||||
end
|
||||
|
||||
warnedLocations[trace] = true
|
||||
|
||||
ReconcilerCompat._warn(trace)
|
||||
end
|
||||
|
||||
function ReconcilerCompat.reify(...)
|
||||
warnOnce(reifyMessage)
|
||||
|
||||
return Reconciler.mount(...)
|
||||
end
|
||||
|
||||
function ReconcilerCompat.teardown(...)
|
||||
warnOnce(teardownMessage)
|
||||
|
||||
return Reconciler.unmount(...)
|
||||
end
|
||||
|
||||
return ReconcilerCompat
|
||||
@@ -0,0 +1,61 @@
|
||||
return function()
|
||||
local ReconcilerCompat = require(script.Parent.ReconcilerCompat)
|
||||
local Reconciler = require(script.Parent.Reconciler)
|
||||
local createElement = require(script.Parent.createElement)
|
||||
|
||||
it("reify should only warn once per call site", function()
|
||||
local callCount = 0
|
||||
local lastMessage
|
||||
ReconcilerCompat._warn = function(message)
|
||||
callCount = callCount + 1
|
||||
lastMessage = message
|
||||
end
|
||||
|
||||
-- We're using a loop so that we get the same stack trace and only one
|
||||
-- warning hopefully.
|
||||
for _ = 1, 2 do
|
||||
local handle = ReconcilerCompat.reify(createElement("StringValue"))
|
||||
Reconciler.unmount(handle)
|
||||
end
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
expect(lastMessage:find("ReconcilerCompat.spec")).to.be.ok()
|
||||
|
||||
-- This is a different call site, which should trigger another warning.
|
||||
local handle = ReconcilerCompat.reify(createElement("StringValue"))
|
||||
Reconciler.unmount(handle)
|
||||
|
||||
expect(callCount).to.equal(2)
|
||||
expect(lastMessage:find("ReconcilerCompat.spec")).to.be.ok()
|
||||
|
||||
ReconcilerCompat._warn = warn
|
||||
end)
|
||||
|
||||
it("teardown should only warn once per call site", function()
|
||||
local callCount = 0
|
||||
local lastMessage
|
||||
ReconcilerCompat._warn = function(message)
|
||||
callCount = callCount + 1
|
||||
lastMessage = message
|
||||
end
|
||||
|
||||
-- We're using a loop so that we get the same stack trace and only one
|
||||
-- warning hopefully.
|
||||
for _ = 1, 2 do
|
||||
local handle = Reconciler.mount(createElement("StringValue"))
|
||||
ReconcilerCompat.teardown(handle)
|
||||
end
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
expect(lastMessage:find("ReconcilerCompat.spec")).to.be.ok()
|
||||
|
||||
-- This is a different call site, which should trigger another warning.
|
||||
local handle = Reconciler.mount(createElement("StringValue"))
|
||||
ReconcilerCompat.teardown(handle)
|
||||
|
||||
expect(callCount).to.equal(2)
|
||||
expect(lastMessage:find("ReconcilerCompat.spec")).to.be.ok()
|
||||
|
||||
ReconcilerCompat._warn = warn
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,145 @@
|
||||
--[[
|
||||
An interface to have one event listener at a time on an event.
|
||||
|
||||
One listener can be registered per SingleEventManager/Instance/Event triple.
|
||||
|
||||
For example:
|
||||
|
||||
myManager:connect(myPart, "Touched", touchedListener)
|
||||
myManager:connect(myPart, "Touched", otherTouchedListener)
|
||||
|
||||
If myPart is touched, only `otherTouchedListener` will fire, because the
|
||||
first listener was disconnected during the second connect call.
|
||||
|
||||
The hooks provided by SingleEventManager pass the associated Roblox object
|
||||
as the first parameter to the callback. This differs from normal
|
||||
Roblox events.
|
||||
]]
|
||||
|
||||
local SingleEventManager = {}
|
||||
|
||||
SingleEventManager.__index = SingleEventManager
|
||||
|
||||
local function createHook(rbx, key, method)
|
||||
local hook = {
|
||||
method = method,
|
||||
connection = rbx[key]:Connect(function(...)
|
||||
method(rbx, ...)
|
||||
end)
|
||||
}
|
||||
|
||||
return hook
|
||||
end
|
||||
|
||||
local function createChangeHook(rbx, key, method)
|
||||
local hook = {
|
||||
method = method,
|
||||
connection = rbx:GetPropertyChangedSignal(key):Connect(function(...)
|
||||
method(rbx, ...)
|
||||
end)
|
||||
}
|
||||
|
||||
return hook
|
||||
end
|
||||
|
||||
local function formatChangeKey(key)
|
||||
return ("!PropertyChangeEvent:%s"):format(key)
|
||||
end
|
||||
|
||||
function SingleEventManager.new()
|
||||
local self = {}
|
||||
|
||||
self._hookCache = {}
|
||||
|
||||
setmetatable(self, SingleEventManager)
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
function SingleEventManager:connect(rbx, key, method)
|
||||
local rbxHooks = self._hookCache[rbx]
|
||||
|
||||
if rbxHooks then
|
||||
local existingHook = rbxHooks[key]
|
||||
|
||||
if existingHook then
|
||||
if existingHook.method == method then
|
||||
return
|
||||
end
|
||||
|
||||
existingHook.connection:Disconnect()
|
||||
end
|
||||
|
||||
rbxHooks[key] = createHook(rbx, key, method)
|
||||
else
|
||||
rbxHooks = {}
|
||||
rbxHooks[key] = createHook(rbx, key, method)
|
||||
|
||||
self._hookCache[rbx] = rbxHooks
|
||||
end
|
||||
end
|
||||
|
||||
function SingleEventManager:connectProperty(rbx, key, method)
|
||||
local rbxHooks = self._hookCache[rbx]
|
||||
local formattedKey = formatChangeKey(key)
|
||||
|
||||
if rbxHooks then
|
||||
local existingHook = rbxHooks[formattedKey]
|
||||
|
||||
if existingHook then
|
||||
if existingHook.method == method then
|
||||
return
|
||||
end
|
||||
|
||||
existingHook.connection:Disconnect()
|
||||
end
|
||||
|
||||
rbxHooks[formattedKey] = createChangeHook(rbx, key, method)
|
||||
else
|
||||
rbxHooks = {}
|
||||
rbxHooks[formattedKey] = createChangeHook(rbx, key, method)
|
||||
|
||||
self._hookCache[rbx] = rbxHooks
|
||||
end
|
||||
end
|
||||
|
||||
function SingleEventManager:disconnect(rbx, key)
|
||||
local rbxHooks = self._hookCache[rbx]
|
||||
|
||||
if not rbxHooks then
|
||||
return
|
||||
end
|
||||
|
||||
local existingHook = rbxHooks[key]
|
||||
|
||||
if not existingHook then
|
||||
return
|
||||
end
|
||||
|
||||
existingHook.connection:Disconnect()
|
||||
rbxHooks[key] = nil
|
||||
|
||||
if next(rbxHooks) == nil then
|
||||
self._hookCache[rbx] = nil
|
||||
end
|
||||
end
|
||||
|
||||
function SingleEventManager:disconnectProperty(rbx, key)
|
||||
self:disconnect(rbx, formatChangeKey(key))
|
||||
end
|
||||
|
||||
function SingleEventManager:disconnectAll(rbx)
|
||||
local rbxHooks = self._hookCache[rbx]
|
||||
|
||||
if not rbxHooks then
|
||||
return
|
||||
end
|
||||
|
||||
for _, hook in pairs(rbxHooks) do
|
||||
hook.connection:Disconnect()
|
||||
end
|
||||
|
||||
self._hookCache[rbx] = nil
|
||||
end
|
||||
|
||||
return SingleEventManager
|
||||
@@ -0,0 +1,275 @@
|
||||
return function()
|
||||
local SingleEventManager = require(script.Parent.SingleEventManager)
|
||||
|
||||
describe("new", function()
|
||||
it("should create a SingleEventManager", function()
|
||||
local manager = SingleEventManager.new()
|
||||
|
||||
expect(manager).to.be.ok()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("connect", function()
|
||||
it("should connect to events on an object", function()
|
||||
local target = Instance.new("BindableEvent")
|
||||
local manager = SingleEventManager.new()
|
||||
|
||||
local callCount = 0
|
||||
|
||||
manager:connect(target, "Event", function(rbx, arg)
|
||||
expect(rbx).to.equal(target)
|
||||
expect(arg).to.equal("foo")
|
||||
callCount = callCount + 1
|
||||
end)
|
||||
|
||||
target:Fire("foo")
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
|
||||
target:Fire("foo")
|
||||
|
||||
expect(callCount).to.equal(2)
|
||||
end)
|
||||
|
||||
it("should only connect one handler at a time", function()
|
||||
local target = Instance.new("BindableEvent")
|
||||
local manager = SingleEventManager.new()
|
||||
|
||||
local callCountA = 0
|
||||
local callCountB = 0
|
||||
|
||||
manager:connect(target, "Event", function(rbx)
|
||||
expect(rbx).to.equal(target)
|
||||
callCountA = callCountA + 1
|
||||
end)
|
||||
|
||||
manager:connect(target, "Event", function(rbx)
|
||||
expect(rbx).to.equal(target)
|
||||
callCountB = callCountB + 1
|
||||
end)
|
||||
|
||||
target:Fire("foo")
|
||||
|
||||
expect(callCountA).to.equal(0)
|
||||
expect(callCountB).to.equal(1)
|
||||
end)
|
||||
|
||||
it("shouldn't conflate different event handlers", function()
|
||||
local target = Instance.new("BindableEvent")
|
||||
local manager = SingleEventManager.new()
|
||||
|
||||
local callCountEvent = 0
|
||||
local callCountChanged = 0
|
||||
|
||||
manager:connect(target, "Event", function(rbx)
|
||||
expect(rbx).to.equal(target)
|
||||
callCountEvent = callCountEvent + 1
|
||||
end)
|
||||
|
||||
manager:connect(target, "Changed", function(rbx)
|
||||
expect(rbx).to.equal(target)
|
||||
callCountChanged = callCountChanged + 1
|
||||
end)
|
||||
|
||||
target:Fire()
|
||||
|
||||
expect(callCountEvent).to.equal(1)
|
||||
expect(callCountChanged).to.equal(0)
|
||||
|
||||
target.Name = "unlimited power!"
|
||||
|
||||
expect(callCountEvent).to.equal(1)
|
||||
expect(callCountChanged).to.equal(1)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("connectProperty", function()
|
||||
it("should connect to property changes", function()
|
||||
local target = Instance.new("BindableEvent")
|
||||
local manager = SingleEventManager.new()
|
||||
|
||||
local changeCount = 0
|
||||
|
||||
manager:connectProperty(target, "Name", function(rbx)
|
||||
changeCount = changeCount + 1
|
||||
end)
|
||||
|
||||
target.Name = "hi"
|
||||
expect(changeCount).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should disconnect the existing connection if present", function()
|
||||
local target = Instance.new("IntValue")
|
||||
local manager = SingleEventManager.new()
|
||||
|
||||
local changeCountA = 0
|
||||
local changeCountB = 0
|
||||
|
||||
manager:connectProperty(target, "Name", function(rbx)
|
||||
changeCountA = changeCountA + 1
|
||||
end)
|
||||
|
||||
manager:connectProperty(target, "Name", function(rbx)
|
||||
changeCountB = changeCountB + 1
|
||||
end)
|
||||
|
||||
target.Name = "hi"
|
||||
expect(changeCountA).to.equal(0)
|
||||
expect(changeCountB).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should only connect to the property specified", function()
|
||||
local target = Instance.new("IntValue")
|
||||
local manager = SingleEventManager.new()
|
||||
|
||||
local changeCount = 0
|
||||
|
||||
manager:connectProperty(target, "Name", function(rbx)
|
||||
changeCount = changeCount + 1
|
||||
end)
|
||||
|
||||
target.Name = "hi"
|
||||
target.Value = 0
|
||||
expect(changeCount).to.equal(1)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("disconnect", function()
|
||||
it("should disconnect handlers on an object", function()
|
||||
local target = Instance.new("BindableEvent")
|
||||
local manager = SingleEventManager.new()
|
||||
|
||||
local callCount = 0
|
||||
|
||||
manager:connect(target, "Event", function(rbx)
|
||||
expect(rbx).to.equal(target)
|
||||
callCount = callCount + 1
|
||||
end)
|
||||
|
||||
target:Fire()
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
|
||||
manager:disconnect(target, "Event")
|
||||
|
||||
target:Fire()
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should not disconnect unrelated connections", function()
|
||||
local target = Instance.new("BindableEvent")
|
||||
local manager = SingleEventManager.new()
|
||||
|
||||
local callCountEvent = 0
|
||||
local callCountChanged = 0
|
||||
|
||||
manager:connect(target, "Event", function(rbx)
|
||||
expect(rbx).to.equal(target)
|
||||
callCountEvent = callCountEvent + 1
|
||||
end)
|
||||
|
||||
manager:connect(target, "Changed", function(rbx)
|
||||
expect(rbx).to.equal(target)
|
||||
callCountChanged = callCountChanged + 1
|
||||
end)
|
||||
|
||||
target:Fire()
|
||||
target.Name = "bar"
|
||||
|
||||
expect(callCountEvent).to.equal(1)
|
||||
expect(callCountChanged).to.equal(1)
|
||||
|
||||
manager:disconnect(target, "Event")
|
||||
|
||||
target:Fire()
|
||||
target.Name = "foo"
|
||||
|
||||
expect(callCountEvent).to.equal(1)
|
||||
expect(callCountChanged).to.equal(2)
|
||||
end)
|
||||
|
||||
it("should succeed with no events attached", function()
|
||||
local manager = SingleEventManager.new()
|
||||
local target = Instance.new("StringValue")
|
||||
|
||||
manager:disconnect(target, "Event")
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("disconnectProperty", function()
|
||||
it("should disconnect property change handlers on an object", function()
|
||||
local target = Instance.new("IntValue")
|
||||
local manager = SingleEventManager.new()
|
||||
|
||||
local changeCount = 0
|
||||
|
||||
manager:connectProperty(target, "Name", function(rbx)
|
||||
changeCount = changeCount + 1
|
||||
end)
|
||||
|
||||
target.Name = "hi"
|
||||
expect(changeCount).to.equal(1)
|
||||
|
||||
manager:disconnectProperty(target, "Name")
|
||||
target.Name = "test"
|
||||
expect(changeCount).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should succeed even if no handler is attached", function()
|
||||
local target = Instance.new("IntValue")
|
||||
local manager = SingleEventManager.new()
|
||||
|
||||
manager:disconnectProperty(target, "Name")
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("disconnectAll", function()
|
||||
it("should disconnect all listeners on an object", function()
|
||||
local target = Instance.new("BindableEvent")
|
||||
local manager = SingleEventManager.new()
|
||||
|
||||
local callCountEvent = 0
|
||||
local callCountChanged = 0
|
||||
local changeCount = 0
|
||||
|
||||
manager:connect(target, "Event", function(rbx)
|
||||
expect(rbx).to.equal(target)
|
||||
callCountEvent = callCountEvent + 1
|
||||
end)
|
||||
|
||||
manager:connect(target, "Changed", function(rbx)
|
||||
expect(rbx).to.equal(target)
|
||||
callCountChanged = callCountChanged + 1
|
||||
end)
|
||||
|
||||
manager:connectProperty(target, "Name", function(rbx)
|
||||
expect(rbx).to.equal(target)
|
||||
changeCount = changeCount + 1
|
||||
end)
|
||||
|
||||
target:Fire()
|
||||
target.Name = "bar"
|
||||
|
||||
expect(callCountEvent).to.equal(1)
|
||||
expect(callCountChanged).to.equal(1)
|
||||
expect(changeCount).to.equal(1)
|
||||
|
||||
manager:disconnectAll(target)
|
||||
|
||||
target:Fire()
|
||||
target.Name = "foo"
|
||||
|
||||
expect(callCountEvent).to.equal(1)
|
||||
expect(callCountChanged).to.equal(1)
|
||||
expect(changeCount).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should succeed with no events attached", function()
|
||||
local target = Instance.new("StringValue")
|
||||
local manager = SingleEventManager.new()
|
||||
|
||||
manager:disconnectAll(target)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
--[[
|
||||
A 'Symbol' is an opaque marker type.
|
||||
|
||||
Symbols have the type 'userdata', but when printed to the console, the name
|
||||
of the symbol is shown.
|
||||
]]
|
||||
|
||||
local Symbol = {}
|
||||
|
||||
--[[
|
||||
Creates a Symbol with the given name.
|
||||
|
||||
When printed or coerced to a string, the symbol will turn into the string
|
||||
given as its name.
|
||||
]]
|
||||
function Symbol.named(name)
|
||||
assert(type(name) == "string", "Symbols must be created using a string name!")
|
||||
|
||||
local self = newproxy(true)
|
||||
|
||||
local wrappedName = ("Symbol(%s)"):format(name)
|
||||
|
||||
getmetatable(self).__tostring = function()
|
||||
return wrappedName
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
--[[
|
||||
Create an unnamed Symbol. Usually, you should create a named Symbol using
|
||||
Symbol.named(name)
|
||||
]]
|
||||
function Symbol.unnamed()
|
||||
local self = newproxy(true)
|
||||
|
||||
getmetatable(self).__tostring = function()
|
||||
return "Unnamed Symbol"
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
return Symbol
|
||||
@@ -0,0 +1,45 @@
|
||||
return function()
|
||||
local Symbol = require(script.Parent.Symbol)
|
||||
|
||||
describe("named", function()
|
||||
it("should give an opaque object", function()
|
||||
local symbol = Symbol.named("foo")
|
||||
|
||||
expect(symbol).to.be.a("userdata")
|
||||
end)
|
||||
|
||||
it("should coerce to the given name", function()
|
||||
local symbol = Symbol.named("foo")
|
||||
|
||||
expect(tostring(symbol):find("foo")).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should be unique when constructed", function()
|
||||
local symbolA = Symbol.named("abc")
|
||||
local symbolB = Symbol.named("abc")
|
||||
|
||||
expect(symbolA).never.to.equal(symbolB)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("unnamed", function()
|
||||
it("should give an opaque object", function()
|
||||
local symbol = Symbol.unnamed()
|
||||
|
||||
expect(symbol).to.be.a("userdata")
|
||||
end)
|
||||
|
||||
it("should coerce to some string", function()
|
||||
local symbol = Symbol.unnamed()
|
||||
|
||||
expect(tostring(symbol)).to.be.a("string")
|
||||
end)
|
||||
|
||||
it("should be unique when constructed", function()
|
||||
local symbolA = Symbol.unnamed()
|
||||
local symbolB = Symbol.unnamed()
|
||||
|
||||
expect(symbolA).never.to.equal(symbolB)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
local Core = require(script.Parent.Core)
|
||||
local GlobalConfig = require(script.Parent.GlobalConfig)
|
||||
|
||||
--[[
|
||||
Creates a new Roact element of the given type.
|
||||
|
||||
Does not create any concrete objects.
|
||||
]]
|
||||
local function createElement(elementType, props, children)
|
||||
if elementType == nil then
|
||||
error(("Expected elementType as an argument to createElement!"), 2)
|
||||
end
|
||||
|
||||
props = props or {}
|
||||
|
||||
if children then
|
||||
if props[Core.Children] ~= nil then
|
||||
warn("props[Children] was defined but was overridden by third parameter to createElement!")
|
||||
end
|
||||
|
||||
props[Core.Children] = children
|
||||
end
|
||||
|
||||
local element = {
|
||||
type = Core.Element,
|
||||
component = elementType,
|
||||
props = props,
|
||||
}
|
||||
|
||||
if GlobalConfig.getValue("elementTracing") then
|
||||
element.source = ("\n%s\n"):format(debug.traceback())
|
||||
end
|
||||
|
||||
return element
|
||||
end
|
||||
|
||||
return createElement
|
||||
@@ -0,0 +1,22 @@
|
||||
return function()
|
||||
local createElement = require(script.Parent.createElement)
|
||||
|
||||
it("should create new primitive elements", function()
|
||||
local element = createElement("Frame")
|
||||
|
||||
expect(element).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should create new functional elements", function()
|
||||
local element = createElement(function()
|
||||
end)
|
||||
|
||||
expect(element).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should create new stateful components", function()
|
||||
local element = createElement({})
|
||||
|
||||
expect(element).to.be.ok()
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
--[[
|
||||
Provides an API for acquiring a reference to a reified object. This
|
||||
API is designed to mimic React 16.3's createRef API.
|
||||
|
||||
See:
|
||||
* https://reactjs.org/docs/refs-and-the-dom.html
|
||||
* https://reactjs.org/blog/2018/03/29/react-v-16-3.html#createref-api
|
||||
]]
|
||||
|
||||
local refMetatable = {
|
||||
__tostring = function(self)
|
||||
return ("RoactReference(%s)"):format(tostring(self.current))
|
||||
end,
|
||||
}
|
||||
|
||||
return function()
|
||||
return setmetatable({
|
||||
current = nil,
|
||||
}, refMetatable)
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
return function()
|
||||
local createRef = require(script.Parent.createRef)
|
||||
|
||||
it("should create refs", function()
|
||||
expect(createRef()).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should support tostring on refs", function()
|
||||
local ref = createRef()
|
||||
expect(tostring(ref)).to.equal("RoactReference(nil)")
|
||||
|
||||
ref.current = "foo"
|
||||
expect(tostring(ref)).to.equal("RoactReference(foo)")
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
--[[
|
||||
Attempts to get the default value of a given property on a Roblox instance.
|
||||
|
||||
This is used by the reconciler in cases where a prop was previously set on a
|
||||
primitive component, but is no longer present in a component's new props.
|
||||
|
||||
Eventually, Roblox might provide a nicer API to query the default property
|
||||
of an object without constructing an instance of it.
|
||||
]]
|
||||
|
||||
local Symbol = require(script.Parent.Symbol)
|
||||
|
||||
local Nil = Symbol.named("Nil")
|
||||
local _cachedPropertyValues = {}
|
||||
|
||||
local function getDefaultPropertyValue(className, propertyName)
|
||||
local classCache = _cachedPropertyValues[className]
|
||||
|
||||
if classCache then
|
||||
local propValue = classCache[propertyName]
|
||||
|
||||
-- We have to use a marker here, because Lua doesn't distinguish
|
||||
-- between 'nil' and 'not in a table'
|
||||
if propValue == Nil then
|
||||
return true, nil
|
||||
end
|
||||
|
||||
if propValue ~= nil then
|
||||
return true, propValue
|
||||
end
|
||||
else
|
||||
classCache = {}
|
||||
_cachedPropertyValues[className] = classCache
|
||||
end
|
||||
|
||||
local created = Instance.new(className)
|
||||
local ok, defaultValue = pcall(function()
|
||||
return created[propertyName]
|
||||
end)
|
||||
|
||||
created:Destroy()
|
||||
|
||||
if ok then
|
||||
if defaultValue == nil then
|
||||
classCache[propertyName] = Nil
|
||||
else
|
||||
classCache[propertyName] = defaultValue
|
||||
end
|
||||
end
|
||||
|
||||
return ok, defaultValue
|
||||
end
|
||||
|
||||
return getDefaultPropertyValue
|
||||
@@ -0,0 +1,33 @@
|
||||
return function()
|
||||
local getDefaultPropertyValue = require(script.Parent.getDefaultPropertyValue)
|
||||
|
||||
it("should get default name string values", function()
|
||||
local _, defaultName = getDefaultPropertyValue("StringValue", "Name")
|
||||
|
||||
expect(defaultName).to.equal("Value")
|
||||
end)
|
||||
|
||||
it("should get default empty string values", function()
|
||||
local _, defaultValue = getDefaultPropertyValue("StringValue", "Value")
|
||||
|
||||
expect(defaultValue).to.equal("")
|
||||
end)
|
||||
|
||||
it("should get default number values", function()
|
||||
local _, defaultValue = getDefaultPropertyValue("IntValue", "Value")
|
||||
|
||||
expect(defaultValue).to.equal(0)
|
||||
end)
|
||||
|
||||
it("should get nil default values", function()
|
||||
local _, defaultValue = getDefaultPropertyValue("ObjectValue", "Value")
|
||||
|
||||
expect(defaultValue).to.equal(nil)
|
||||
end)
|
||||
|
||||
it("should get bool default values", function()
|
||||
local _, defaultValue = getDefaultPropertyValue("BoolValue", "Value")
|
||||
|
||||
expect(defaultValue).to.equal(false)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
--[[
|
||||
Packages up the internals of Roact and exposes a public API for it.
|
||||
]]
|
||||
|
||||
local Change = require(script.Change)
|
||||
local Component = require(script.Component)
|
||||
local Core = require(script.Core)
|
||||
local createElement = require(script.createElement)
|
||||
local createRef = require(script.createRef)
|
||||
local Event = require(script.Event)
|
||||
local GlobalConfig = require(script.GlobalConfig)
|
||||
local Instrumentation = require(script.Instrumentation)
|
||||
local oneChild = require(script.oneChild)
|
||||
local PureComponent = require(script.PureComponent)
|
||||
local Reconciler = require(script.Reconciler)
|
||||
local ReconcilerCompat = require(script.ReconcilerCompat)
|
||||
|
||||
--[[
|
||||
A utility to copy one module into another, erroring if there are
|
||||
overlapping keys.
|
||||
|
||||
Any keys that begin with an underscore are considered private.
|
||||
]]
|
||||
local function apply(target, source)
|
||||
for key, value in pairs(source) do
|
||||
if target[key] ~= nil then
|
||||
error(("Roact: key %q was overridden!"):format(key), 2)
|
||||
end
|
||||
|
||||
-- Don't add internal values
|
||||
if not key:find("^_") then
|
||||
target[key] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local Roact = {}
|
||||
|
||||
apply(Roact, Core)
|
||||
apply(Roact, Reconciler)
|
||||
apply(Roact, ReconcilerCompat)
|
||||
|
||||
apply(Roact, {
|
||||
Change = Change,
|
||||
Component = Component,
|
||||
createElement = createElement,
|
||||
createRef = createRef,
|
||||
Event = Event,
|
||||
oneChild = oneChild,
|
||||
PureComponent = PureComponent,
|
||||
})
|
||||
|
||||
apply(Roact, {
|
||||
setGlobalConfig = GlobalConfig.set,
|
||||
getGlobalConfigValue = GlobalConfig.getValue,
|
||||
})
|
||||
|
||||
apply(Roact, {
|
||||
-- APIs that may change in the future
|
||||
UNSTABLE = {
|
||||
getCollectedStats = Instrumentation.getCollectedStats,
|
||||
clearCollectedStats = Instrumentation.clearCollectedStats,
|
||||
}
|
||||
})
|
||||
|
||||
return Roact
|
||||
@@ -0,0 +1,495 @@
|
||||
return function()
|
||||
local Roact = require(script.Parent)
|
||||
|
||||
it("should load with all public APIs", function()
|
||||
local publicApi = {
|
||||
createElement = "function",
|
||||
createRef = "function",
|
||||
mount = "function",
|
||||
unmount = "function",
|
||||
reconcile = "function",
|
||||
oneChild = "function",
|
||||
setGlobalConfig = "function",
|
||||
getGlobalConfigValue = "function",
|
||||
|
||||
-- These functions are deprecated and will throw warnings soon!
|
||||
reify = "function",
|
||||
teardown = "function",
|
||||
|
||||
Component = true,
|
||||
PureComponent = true,
|
||||
Portal = true,
|
||||
Children = true,
|
||||
Event = true,
|
||||
Change = true,
|
||||
Ref = true,
|
||||
None = true,
|
||||
Element = true,
|
||||
UNSTABLE = true,
|
||||
}
|
||||
|
||||
expect(Roact).to.be.ok()
|
||||
|
||||
for key, valueType in pairs(publicApi) do
|
||||
local success
|
||||
if typeof(valueType) == "string" then
|
||||
success = typeof(Roact[key]) == valueType
|
||||
else
|
||||
success = Roact[key] ~= nil
|
||||
end
|
||||
|
||||
if not success then
|
||||
local existence = typeof(valueType) == "boolean" and "present" or "of type " .. valueType
|
||||
local message = (
|
||||
"Expected public API member %q to be %s, but instead it was of type %s"
|
||||
):format(tostring(key), existence, typeof(Roact[key]))
|
||||
|
||||
error(message)
|
||||
end
|
||||
end
|
||||
|
||||
for key in pairs(Roact) do
|
||||
if publicApi[key] == nil then
|
||||
local message = (
|
||||
"Found unknown public API key %q!"
|
||||
):format(tostring(key))
|
||||
|
||||
error(message)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
describe("Props", function()
|
||||
it("should be passed to primitive components", function()
|
||||
local container = Instance.new("IntValue")
|
||||
|
||||
local element = Roact.createElement("StringValue", {
|
||||
Value = "foo",
|
||||
})
|
||||
|
||||
Roact.mount(element, container, "TestStringValue")
|
||||
|
||||
local rbx = container:FindFirstChild("TestStringValue")
|
||||
|
||||
expect(rbx).to.be.ok()
|
||||
expect(rbx.Value).to.equal("foo")
|
||||
end)
|
||||
|
||||
it("should be passed to functional components", function()
|
||||
local testProp = {}
|
||||
|
||||
local callCount = 0
|
||||
|
||||
local function TestComponent(props)
|
||||
expect(props.testProp).to.equal(testProp)
|
||||
callCount = callCount + 1
|
||||
end
|
||||
|
||||
local element = Roact.createElement(TestComponent, {
|
||||
testProp = testProp,
|
||||
})
|
||||
|
||||
Roact.mount(element)
|
||||
|
||||
-- The only guarantee is that the function will be invoked at least once
|
||||
expect(callCount > 0).to.equal(true)
|
||||
end)
|
||||
|
||||
it("should be passed to stateful components", function()
|
||||
local testProp = {}
|
||||
|
||||
local callCount = 0
|
||||
|
||||
local TestComponent = Roact.Component:extend("TestComponent")
|
||||
|
||||
function TestComponent:init(props)
|
||||
expect(props.testProp).to.equal(testProp)
|
||||
callCount = callCount + 1
|
||||
end
|
||||
|
||||
function TestComponent:render()
|
||||
end
|
||||
|
||||
local element = Roact.createElement(TestComponent, {
|
||||
testProp = testProp,
|
||||
})
|
||||
|
||||
Roact.mount(element)
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("State", function()
|
||||
it("should trigger a re-render of child components", function()
|
||||
local renderCount = 0
|
||||
local listener = nil
|
||||
|
||||
local TestChild = Roact.Component:extend("TestChild")
|
||||
|
||||
function TestChild:render()
|
||||
renderCount = renderCount + 1
|
||||
return nil
|
||||
end
|
||||
|
||||
local TestParent = Roact.Component:extend("TestParent")
|
||||
|
||||
function TestParent:init(props)
|
||||
self.state = {
|
||||
value = 0,
|
||||
}
|
||||
end
|
||||
|
||||
function TestParent:didMount()
|
||||
listener = function()
|
||||
self:setState({
|
||||
value = self.state.value + 1,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function TestParent:render()
|
||||
return Roact.createElement(TestChild, {
|
||||
value = self.state.value,
|
||||
})
|
||||
end
|
||||
|
||||
local element = Roact.createElement(TestParent)
|
||||
Roact.mount(element)
|
||||
|
||||
expect(renderCount >= 1).to.equal(true)
|
||||
expect(listener).to.be.a("function")
|
||||
|
||||
listener()
|
||||
|
||||
expect(renderCount >= 2).to.equal(true)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("Context", function()
|
||||
it("should be passed to children through primitive and functional components", function()
|
||||
local testValue = {}
|
||||
|
||||
local callCount = 0
|
||||
|
||||
local ContextConsumer = Roact.Component:extend("ContextConsumer")
|
||||
|
||||
function ContextConsumer:init(props)
|
||||
expect(self._context.testValue).to.equal(testValue)
|
||||
|
||||
callCount = callCount + 1
|
||||
end
|
||||
|
||||
function ContextConsumer:render()
|
||||
return
|
||||
end
|
||||
|
||||
local function ContextBarrier(props)
|
||||
return Roact.createElement(ContextConsumer)
|
||||
end
|
||||
|
||||
local ContextProvider = Roact.Component:extend("ContextProvider")
|
||||
|
||||
function ContextProvider:init(props)
|
||||
self._context.testValue = props.testValue
|
||||
end
|
||||
|
||||
function ContextProvider:render()
|
||||
return Roact.createElement("Frame", {}, {
|
||||
Child = Roact.createElement(ContextBarrier),
|
||||
})
|
||||
end
|
||||
|
||||
local element = Roact.createElement(ContextProvider, {
|
||||
testValue = testValue,
|
||||
})
|
||||
|
||||
Roact.mount(element)
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("Ref", function()
|
||||
it("should call back with a Roblox object after properties and children", function()
|
||||
local callCount = 0
|
||||
|
||||
local function ref(rbx)
|
||||
expect(rbx).to.be.ok()
|
||||
expect(rbx.ClassName).to.equal("StringValue")
|
||||
expect(rbx.Value).to.equal("Hey!")
|
||||
expect(rbx.Name).to.equal("RefTest")
|
||||
expect(#rbx:GetChildren()).to.equal(1)
|
||||
|
||||
callCount = callCount + 1
|
||||
end
|
||||
|
||||
local element = Roact.createElement("StringValue", {
|
||||
Value = "Hey!",
|
||||
[Roact.Ref] = ref,
|
||||
}, {
|
||||
TestChild = Roact.createElement("StringValue"),
|
||||
})
|
||||
|
||||
Roact.mount(element, nil, "RefTest")
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should pass nil to refs for tearing down", function()
|
||||
local callCount = 0
|
||||
local currentRef
|
||||
|
||||
local function ref(rbx)
|
||||
currentRef = rbx
|
||||
callCount = callCount + 1
|
||||
end
|
||||
|
||||
local element = Roact.createElement("StringValue", {
|
||||
[Roact.Ref] = ref,
|
||||
})
|
||||
|
||||
local instance = Roact.mount(element, nil, "RefTest")
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
expect(currentRef).to.be.ok()
|
||||
expect(currentRef.Name).to.equal("RefTest")
|
||||
|
||||
Roact.unmount(instance)
|
||||
|
||||
expect(callCount).to.equal(2)
|
||||
expect(currentRef).to.equal(nil)
|
||||
end)
|
||||
|
||||
it("should tear down refs when switched out of the tree", function()
|
||||
local updateMethod
|
||||
local refCount = 0
|
||||
local currentRef
|
||||
|
||||
local function ref(rbx)
|
||||
currentRef = rbx
|
||||
refCount = refCount + 1
|
||||
end
|
||||
|
||||
local function RefWrapper()
|
||||
return Roact.createElement("StringValue", {
|
||||
Value = "ooba ooba",
|
||||
[Roact.Ref] = ref,
|
||||
})
|
||||
end
|
||||
|
||||
local Root = Roact.Component:extend("RefTestRoot")
|
||||
|
||||
function Root:init()
|
||||
updateMethod = function(show)
|
||||
self:setState({
|
||||
show = show,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function Root:render()
|
||||
if self.state.show then
|
||||
return Roact.createElement(RefWrapper)
|
||||
end
|
||||
end
|
||||
|
||||
local element = Roact.createElement(Root)
|
||||
Roact.mount(element)
|
||||
|
||||
expect(refCount).to.equal(0)
|
||||
expect(currentRef).to.equal(nil)
|
||||
|
||||
updateMethod(true)
|
||||
|
||||
expect(refCount).to.equal(1)
|
||||
expect(currentRef.Value).to.equal("ooba ooba")
|
||||
|
||||
updateMethod(false)
|
||||
|
||||
expect(refCount).to.equal(2)
|
||||
expect(currentRef).to.equal(nil)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("Portal", function()
|
||||
it("should place all children as children of the target Roblox instance", function()
|
||||
local target = Instance.new("Folder")
|
||||
|
||||
local function FunctionalComponent(props)
|
||||
local intValue = props.value
|
||||
|
||||
return Roact.createElement("IntValue", {
|
||||
Value = intValue,
|
||||
})
|
||||
end
|
||||
|
||||
local portal = Roact.createElement(Roact.Portal, {
|
||||
target = target
|
||||
}, {
|
||||
folderOne = Roact.createElement("Folder"),
|
||||
folderTwo = Roact.createElement("Folder"),
|
||||
intValueOne = Roact.createElement(FunctionalComponent, {
|
||||
value = 42,
|
||||
}),
|
||||
})
|
||||
Roact.mount(portal)
|
||||
|
||||
expect(target:FindFirstChild("folderOne")).to.be.ok()
|
||||
expect(target:FindFirstChild("folderTwo")).to.be.ok()
|
||||
expect(target:FindFirstChild("intValueOne")).to.be.ok()
|
||||
expect(target:FindFirstChild("intValueOne").Value).to.equal(42)
|
||||
end)
|
||||
|
||||
it("should error if the target is nil", function()
|
||||
local portal = Roact.createElement(Roact.Portal, {}, {
|
||||
folderOne = Roact.createElement("Folder"),
|
||||
folderTwo = Roact.createElement("Folder"),
|
||||
})
|
||||
|
||||
expect(function()
|
||||
Roact.mount(portal)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should error if the target is not a Roblox instance", function()
|
||||
local portal = Roact.createElement(Roact.Portal, {
|
||||
target = "NotARobloxInstance",
|
||||
}, {
|
||||
folderOne = Roact.createElement("Folder"),
|
||||
folderTwo = Roact.createElement("Folder"),
|
||||
})
|
||||
|
||||
expect(function()
|
||||
Roact.mount(portal)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should update if parent changes the target", function()
|
||||
local targetOne = Instance.new("Folder")
|
||||
local targetTwo = Instance.new("Folder")
|
||||
local countWillUnmount = 0
|
||||
local changeState
|
||||
|
||||
local TestUnmountComponent = Roact.Component:extend("TestUnmountComponent")
|
||||
|
||||
function TestUnmountComponent:render()
|
||||
return nil
|
||||
end
|
||||
|
||||
function TestUnmountComponent:willUnmount()
|
||||
countWillUnmount = countWillUnmount + 1
|
||||
end
|
||||
|
||||
local PortalContainer = Roact.Component:extend("PortalContainer")
|
||||
|
||||
function PortalContainer:init()
|
||||
self.state = {
|
||||
target = targetOne,
|
||||
}
|
||||
end
|
||||
|
||||
function PortalContainer:render()
|
||||
return Roact.createElement(Roact.Portal, {
|
||||
target = self.state.target,
|
||||
}, {
|
||||
folderOne = Roact.createElement("Folder"),
|
||||
folderTwo = Roact.createElement("Folder"),
|
||||
testUnmount = Roact.createElement(TestUnmountComponent),
|
||||
})
|
||||
end
|
||||
|
||||
function PortalContainer:didMount()
|
||||
expect(self.state.target:FindFirstChild("folderOne")).to.be.ok()
|
||||
expect(self.state.target:FindFirstChild("folderTwo")).to.be.ok()
|
||||
|
||||
changeState = function(newState)
|
||||
self:setState(newState)
|
||||
end
|
||||
end
|
||||
|
||||
Roact.mount(Roact.createElement(PortalContainer))
|
||||
|
||||
expect(targetOne:FindFirstChild("folderOne")).to.be.ok()
|
||||
expect(targetOne:FindFirstChild("folderTwo")).to.be.ok()
|
||||
|
||||
changeState({
|
||||
target = targetTwo,
|
||||
})
|
||||
|
||||
expect(countWillUnmount).to.equal(1)
|
||||
|
||||
expect(targetOne:FindFirstChild("folderOne")).never.to.be.ok()
|
||||
expect(targetOne:FindFirstChild("folderTwo")).never.to.be.ok()
|
||||
expect(targetTwo:FindFirstChild("folderOne")).to.be.ok()
|
||||
expect(targetTwo:FindFirstChild("folderTwo")).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should update Roblox instance properties when relevant parent props are changed", function()
|
||||
local target = Instance.new("Folder")
|
||||
local changeState
|
||||
|
||||
local PortalContainer = Roact.Component:extend("PortalContainer")
|
||||
|
||||
function PortalContainer:init()
|
||||
self.state = {
|
||||
value = "initialStringValue",
|
||||
}
|
||||
end
|
||||
|
||||
function PortalContainer:render()
|
||||
return Roact.createElement(Roact.Portal, {
|
||||
target = target,
|
||||
}, {
|
||||
TestStringValue = Roact.createElement("StringValue", {
|
||||
Value = self.state.value,
|
||||
})
|
||||
})
|
||||
end
|
||||
|
||||
function PortalContainer:didMount()
|
||||
changeState = function(newState)
|
||||
self:setState(newState)
|
||||
end
|
||||
end
|
||||
|
||||
Roact.mount(Roact.createElement(PortalContainer))
|
||||
|
||||
expect(target:FindFirstChild("TestStringValue")).to.be.ok()
|
||||
expect(target:FindFirstChild("TestStringValue").Value).to.equal("initialStringValue")
|
||||
|
||||
changeState({
|
||||
value = "newStringValue",
|
||||
})
|
||||
|
||||
expect(target:FindFirstChild("TestStringValue")).to.be.ok()
|
||||
expect(target:FindFirstChild("TestStringValue").Value).to.equal("newStringValue")
|
||||
end)
|
||||
|
||||
it("should properly teardown the Portal", function()
|
||||
local target = Instance.new("Folder")
|
||||
|
||||
local portal = Roact.createElement(Roact.Portal, {
|
||||
target = target
|
||||
}, {
|
||||
folderOne = Roact.createElement("Folder"),
|
||||
folderTwo = Roact.createElement("Folder"),
|
||||
})
|
||||
local instance = Roact.mount(portal)
|
||||
|
||||
local folderThree = Instance.new("Folder")
|
||||
folderThree.Name = "folderThree"
|
||||
folderThree.Parent = target
|
||||
|
||||
expect(target:FindFirstChild("folderOne")).to.be.ok()
|
||||
expect(target:FindFirstChild("folderTwo")).to.be.ok()
|
||||
expect(target:FindFirstChild("folderThree")).to.be.ok()
|
||||
|
||||
Roact.unmount(instance)
|
||||
|
||||
expect(target:FindFirstChild("folderOne")).never.to.be.ok()
|
||||
expect(target:FindFirstChild("folderTwo")).never.to.be.ok()
|
||||
expect(target:FindFirstChild("folderThree")).to.be.ok()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
--[[
|
||||
These messages are used by Component to help users diagnose when they're
|
||||
calling setState in inappropriate places.
|
||||
|
||||
The indentation may seem odd, but it's necessary to avoid introducing extra
|
||||
whitespace into the error messages themselves.
|
||||
]]
|
||||
|
||||
local invalidSetStateMessages = {}
|
||||
|
||||
invalidSetStateMessages["willUpdate"] = [[
|
||||
setState cannot be used in the willUpdate lifecycle method.
|
||||
Consider using the didUpdate method instead, or using getDerivedStateFromProps.
|
||||
|
||||
Check the definition of willUpdate in the component %q.]]
|
||||
|
||||
invalidSetStateMessages["willUnmount"] = [[
|
||||
setState cannot be used in the willUnmount lifecycle method.
|
||||
A component that is being unmounted cannot be updated!
|
||||
|
||||
Check the definition of willUnmount in the component %q.]]
|
||||
|
||||
invalidSetStateMessages["shouldUpdate"] = [[
|
||||
setState cannot be used in the shouldUpdate lifecycle method.
|
||||
shouldUpdate must be a pure function that only depends on props and state.
|
||||
|
||||
Check the definition of shouldUpdate in the component %q.]]
|
||||
|
||||
invalidSetStateMessages["init"] = [[
|
||||
setState cannot be used in the init method.
|
||||
During init, the component hasn't initialized yet, and isn't ready to render.
|
||||
|
||||
Instead, set the `state` value directly:
|
||||
|
||||
self.state = {
|
||||
value = "foo"
|
||||
}
|
||||
|
||||
Check the definition of init in the component %q.]]
|
||||
|
||||
invalidSetStateMessages["render"] = [[
|
||||
setState cannot be used in the render method.
|
||||
render must be a pure function that only depends on props and state.
|
||||
|
||||
Check the definition of render in the component %q.]]
|
||||
|
||||
invalidSetStateMessages["reconcile"] = [[
|
||||
setState cannot be called while a component is being reified or reconciled.
|
||||
This is the step where Roact constructs Roblox instances, and starting another
|
||||
render here would introduce bugs.
|
||||
|
||||
Check the component %q to see if setState is being called by:
|
||||
* a child Ref
|
||||
* a child Changed event
|
||||
* a child's render method]]
|
||||
|
||||
invalidSetStateMessages["default"] = [[
|
||||
setState can not be used in the current situation, but Roact couldn't find a
|
||||
message to display.
|
||||
|
||||
This is a bug in Roact.
|
||||
It was triggered by the component %q.
|
||||
]]
|
||||
|
||||
return invalidSetStateMessages
|
||||
@@ -0,0 +1,28 @@
|
||||
--[[
|
||||
Utility to retrieve one child out the children passed to a component.
|
||||
|
||||
If passed nil or an empty table, will return nil.
|
||||
|
||||
Throws an error if passed more than one child, but can be passed zero.
|
||||
]]
|
||||
local function oneChild(children)
|
||||
if not children then
|
||||
return
|
||||
end
|
||||
|
||||
local key, child = next(children)
|
||||
|
||||
if not child then
|
||||
return
|
||||
end
|
||||
|
||||
local after = next(children, key)
|
||||
|
||||
if after then
|
||||
error("Expected at most child, had more than one child.", 2)
|
||||
end
|
||||
|
||||
return child
|
||||
end
|
||||
|
||||
return oneChild
|
||||
@@ -0,0 +1,35 @@
|
||||
return function()
|
||||
local createElement = require(script.Parent.createElement)
|
||||
|
||||
local oneChild = require(script.Parent.oneChild)
|
||||
|
||||
it("should get zero children from a table", function()
|
||||
local children = {}
|
||||
|
||||
expect(oneChild(children)).to.equal(nil)
|
||||
end)
|
||||
|
||||
it("should get exactly one child", function()
|
||||
local child = createElement("Frame")
|
||||
local children = {
|
||||
foo = child,
|
||||
}
|
||||
|
||||
expect(oneChild(children)).to.equal(child)
|
||||
end)
|
||||
|
||||
it("should error with more than one child", function()
|
||||
local children = {
|
||||
a = createElement("Frame"),
|
||||
b = createElement("Frame"),
|
||||
}
|
||||
|
||||
expect(function()
|
||||
oneChild(children)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should handle being passed nil", function()
|
||||
expect(oneChild(nil)).to.equal(nil)
|
||||
end)
|
||||
end
|
||||
Reference in New Issue
Block a user