This commit is contained in:
lx
2026-07-06 14:01:11 -04:00
commit c4f97d729d
16468 changed files with 935321 additions and 0 deletions
@@ -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