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,21 @@
local Roact = require(script.Parent.Parent.Roact)
local storeKey = require(script.Parent.storeKey)
local StoreProvider = Roact.Component:extend("StoreProvider")
function StoreProvider:init(props)
local store = props.store
if store == nil then
error("Error initializing StoreProvider. Expected a `store` prop to be a Rodux store.")
end
self._context[storeKey] = store
end
function StoreProvider:render()
return Roact.oneChild(self.props[Roact.Children])
end
return StoreProvider
@@ -0,0 +1,30 @@
return function()
local StoreProvider = require(script.Parent.StoreProvider)
local Roact = require(script.Parent.Parent.Roact)
local Rodux = require(script.Parent.Parent.Rodux)
it("should be instantiable as a component", function()
local store = Rodux.Store.new(function()
return 0
end)
local element = Roact.createElement(StoreProvider, {
store = store
})
expect(element).to.be.ok()
local handle = Roact.mount(element, nil, "StoreProvider-test")
Roact.unmount(handle)
store:destruct()
end)
it("should expect a 'store' prop", function()
local element = Roact.createElement(StoreProvider)
expect(function()
Roact.mount(element)
end).to.throw()
end)
end
@@ -0,0 +1,43 @@
--[[
A 'Symbol' is an opaque marker type that can be used to signify unique
statuses. 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,3 @@
return {
newConnectionOrder = true,
}
@@ -0,0 +1,201 @@
local Roact = require(script.Parent.Parent.Roact)
local getStore = require(script.Parent.getStore)
local shallowEqual = require(script.Parent.shallowEqual)
local join = require(script.Parent.join)
local TempConfig = require(script.Parent.TempConfig)
--[[
Formats a multi-line message with printf-style placeholders.
]]
local function formatMessage(lines, parameters)
return table.concat(lines, "\n"):format(unpack(parameters or {}))
end
local function noop()
return nil
end
--[[
The stateUpdater accepts props when they update and computes the
complete set of props that should be passed to the wrapped component.
Each connected component will have a stateUpdater created for it.
stateUpdater is put into the component's state in order for
getDerivedStateFromProps to be able to access it. It is not mutated.
]]
local function makeStateUpdater(store)
return function(nextProps, prevState, mappedStoreState)
-- The caller can optionally provide mappedStoreState if it needed that
-- value beforehand. Doing so is purely an optimization.
if mappedStoreState == nil then
mappedStoreState = prevState.mapStateToProps(store:getState(), nextProps)
end
local propsForChild = join(nextProps, mappedStoreState, prevState.mappedStoreDispatch)
return {
mappedStoreState = mappedStoreState,
propsForChild = propsForChild,
}
end
end
--[[
mapStateToProps:
(storeState, props) -> partialProps
OR
() -> (storeState, props) -> partialProps
mapDispatchToProps: (dispatch) -> partialProps
]]
local function connect(mapStateToPropsOrThunk, mapDispatchToProps)
local connectTrace = debug.traceback()
if mapStateToPropsOrThunk ~= nil then
assert(typeof(mapStateToPropsOrThunk) == "function", "mapStateToProps must be a function or nil!")
else
mapStateToPropsOrThunk = noop
end
if mapDispatchToProps ~= nil then
assert(typeof(mapDispatchToProps) == "function", "mapDispatchToProps must be a function or nil!")
else
mapDispatchToProps = noop
end
return function(innerComponent)
if innerComponent == nil then
local message = formatMessage({
"connect returns a function that must be passed a component.",
"Check the connection at:",
"%s",
}, {
connectTrace,
})
error(message, 2)
end
local componentName = ("RoduxConnection(%s)"):format(tostring(innerComponent))
local Connection = Roact.Component:extend(componentName)
function Connection.getDerivedStateFromProps(nextProps, prevState)
if prevState.stateUpdater ~= nil then
return prevState.stateUpdater(nextProps, prevState)
end
end
function Connection:createStoreConnection()
self.storeChangedConnection = self.store.changed:connect(function(storeState)
self:setState(function(prevState, props)
local mappedStoreState = prevState.mapStateToProps(storeState, props)
-- We run this check here so that we only check shallow
-- equality with the result of mapStateToProps, and not the
-- other props that could be passed through the connector.
if shallowEqual(mappedStoreState, prevState.mappedStoreState) then
return nil
end
return prevState.stateUpdater(props, prevState, mappedStoreState)
end)
end)
end
function Connection:init()
self.store = getStore(self)
if self.store == nil then
local message = formatMessage({
"Cannot initialize Roact-Rodux connection without being a descendent of StoreProvider!",
"Tried to wrap component %q",
"Make sure there is a StoreProvider above this component in the tree.",
}, {
tostring(innerComponent),
})
error(message)
end
local storeState = self.store:getState()
local mapStateToProps = mapStateToPropsOrThunk
local mappedStoreState = mapStateToProps(storeState, self.props)
-- mapStateToPropsOrThunk can return a function instead of a state
-- value. In this variant, we keep that value as mapStateToProps
-- instead of the original mapStateToProps. This matches react-redux
-- and enables connectors to keep instance-level state.
if typeof(mappedStoreState) == "function" then
mapStateToProps = mappedStoreState
mappedStoreState = mapStateToProps(storeState, self.props)
end
if mappedStoreState ~= nil and typeof(mappedStoreState) ~= "table" then
local message = formatMessage({
"mapStateToProps must either return a table, or return another function that returns a table.",
"Instead, it returned %q, which is of type %s.",
}, {
tostring(mappedStoreState),
typeof(mappedStoreState),
})
error(message)
end
local mappedStoreDispatch = mapDispatchToProps(function(...)
return self.store:dispatch(...)
end)
local stateUpdater = makeStateUpdater(self.store)
self.state = {
-- Combines props, mappedStoreDispatch, and the result of
-- mapStateToProps into propsForChild. Stored in state so that
-- getDerivedStateFromProps can access it.
stateUpdater = stateUpdater,
-- Used by the store changed connection and stateUpdater to
-- construct propsForChild.
mapStateToProps = mapStateToProps,
-- Used by stateUpdater to construct propsForChild.
mappedStoreDispatch = mappedStoreDispatch,
-- Passed directly into the component that Connection is
-- wrapping.
propsForChild = nil,
}
local extraState = stateUpdater(self.props, self.state, mappedStoreState)
for key, value in pairs(extraState) do
self.state[key] = value
end
if TempConfig.newConnectionOrder then
self:createStoreConnection()
end
end
function Connection:didMount()
if not TempConfig.newConnectionOrder then
self:createStoreConnection()
end
end
function Connection:willUnmount()
self.storeChangedConnection:disconnect()
end
function Connection:render()
return Roact.createElement(innerComponent, self.state.propsForChild)
end
return Connection
end
end
return connect
@@ -0,0 +1,353 @@
return function()
local connect = require(script.Parent.connect)
local StoreProvider = require(script.Parent.StoreProvider)
local Roact = require(script.Parent.Parent.Roact)
local Rodux = require(script.Parent.Parent.Rodux)
local TempConfig = require(script.Parent.TempConfig)
local function noop()
return nil
end
local function NoopComponent()
return nil
end
local function countReducer(state, action)
state = state or 0
if action.type == "increment" then
return state + 1
end
return state
end
local reducer = Rodux.combineReducers({
count = countReducer,
})
describe("Argument validation", function()
it("should accept no arguments", function()
connect()
end)
it("should accept one function", function()
connect(noop)
end)
it("should accept two functions", function()
connect(noop, noop)
end)
it("should accept only the second function", function()
connect(nil, function() end)
end)
it("should throw if not passed a component", function()
local selector = function(store)
return {}
end
expect(function()
connect(selector)(nil)
end).to.throw()
end)
end)
it("should throw if not mounted under a StoreProvider", function()
local ConnectedSomeComponent = connect()(NoopComponent)
expect(function()
Roact.mount(Roact.createElement(ConnectedSomeComponent))
end).to.throw()
end)
it("should accept a higher-order function mapStateToProps", function()
local function mapStateToProps()
return function(state)
return {
count = state.count,
}
end
end
local ConnectedSomeComponent = connect(mapStateToProps)(NoopComponent)
local store = Rodux.Store.new(reducer)
local tree = Roact.createElement(StoreProvider, {
store = store,
}, {
someComponent = Roact.createElement(ConnectedSomeComponent),
})
local handle = Roact.mount(tree)
Roact.unmount(handle)
end)
it("should not accept a higher-order mapStateToProps that returns a non-table value", function()
local function mapStateToProps()
return function(state)
return "nope"
end
end
local ConnectedSomeComponent = connect(mapStateToProps)(NoopComponent)
local store = Rodux.Store.new(reducer)
local tree = Roact.createElement(StoreProvider, {
store = store,
}, {
someComponent = Roact.createElement(ConnectedSomeComponent),
})
expect(function()
Roact.mount(tree)
end).to.throw()
end)
it("should not accept a mapStateToProps that returns a non-table value", function()
local function mapStateToProps()
return "nah"
end
local ConnectedSomeComponent = connect(mapStateToProps)(NoopComponent)
local store = Rodux.Store.new(reducer)
local tree = Roact.createElement(StoreProvider, {
store = store,
}, {
someComponent = Roact.createElement(ConnectedSomeComponent),
})
expect(function()
Roact.mount(tree)
end).to.throw()
end)
it("should abort renders when mapStateToProps returns the same data", function()
local function mapStateToProps(state)
return {
count = state.count,
}
end
local renderCount = 0
local function SomeComponent(props)
renderCount = renderCount + 1
end
local ConnectedSomeComponent = connect(mapStateToProps)(SomeComponent)
local store = Rodux.Store.new(reducer)
local tree = Roact.createElement(StoreProvider, {
store = store,
}, {
someComponent = Roact.createElement(ConnectedSomeComponent),
})
local handle = Roact.mount(tree)
expect(renderCount).to.equal(1)
store:dispatch({ type = "an unknown action" })
store:flush()
expect(renderCount).to.equal(1)
store:dispatch({ type = "increment" })
store:flush()
expect(renderCount).to.equal(2)
Roact.unmount(handle)
end)
it("should only call mapDispatchToProps once and never re-render if no mapStateToProps was passed", function()
local dispatchCount = 0
local mapDispatchToProps = function(dispatch)
dispatchCount = dispatchCount + 1
return {
increment = function()
return dispatch({ type = "increment" })
end,
}
end
local renderCount = 0
local function SomeComponent(props)
renderCount = renderCount + 1
end
local ConnectedSomeComponent = connect(nil, mapDispatchToProps)(SomeComponent)
local store = Rodux.Store.new(reducer)
local tree = Roact.createElement(StoreProvider, {
store = store,
}, {
someComponent = Roact.createElement(ConnectedSomeComponent),
})
local handle = Roact.mount(tree)
expect(dispatchCount).to.equal(1)
expect(renderCount).to.equal(1)
store:dispatch({ type = "an unknown action" })
store:flush()
expect(dispatchCount).to.equal(1)
expect(renderCount).to.equal(1)
store:dispatch({ type = "increment" })
store:flush()
expect(dispatchCount).to.equal(1)
expect(renderCount).to.equal(1)
Roact.unmount(handle)
end)
it("should return result values from the dispatch passed to mapDispatchToProps", function()
local function reducer()
return 0
end
local function fiveThunk()
return 5
end
local dispatch
local function SomeComponent(props)
dispatch = props.dispatch
end
local function mapDispatchToProps(dispatch)
return {
dispatch = dispatch
}
end
local ConnectedSomeComponent = connect(nil, mapDispatchToProps)(SomeComponent)
-- We'll use the thunk middleware, as it should always return its result
local store = Rodux.Store.new(reducer, nil, { Rodux.thunkMiddleware })
local tree = Roact.createElement(StoreProvider, {
store = store,
}, {
someComponent = Roact.createElement(ConnectedSomeComponent)
})
local handle = Roact.mount(tree)
expect(dispatch).to.be.a("function")
expect(dispatch(fiveThunk)).to.equal(5)
Roact.unmount(handle)
end)
it("should render parent elements before children", function()
local oldNewConnectionOrder = TempConfig.newConnectionOrder
TempConfig.newConnectionOrder = true
local function mapStateToProps(state)
return {
count = state.count,
}
end
local childWasRenderedFirst = false
local function ChildComponent(props)
if props.count > props.parentCount then
childWasRenderedFirst = true
end
end
local ConnectedChildComponent = connect(mapStateToProps)(ChildComponent)
local function ParentComponent(props)
return Roact.createElement(ConnectedChildComponent, {
parentCount = props.count,
})
end
local ConnectedParentComponent = connect(mapStateToProps)(ParentComponent)
local store = Rodux.Store.new(reducer)
local tree = Roact.createElement(StoreProvider, {
store = store,
}, {
parent = Roact.createElement(ConnectedParentComponent),
})
local handle = Roact.mount(tree)
store:dispatch({ type = "increment" })
store:flush()
store:dispatch({ type = "increment" })
store:flush()
Roact.unmount(handle)
expect(childWasRenderedFirst).to.equal(false)
TempConfig.newConnectionOrder = oldNewConnectionOrder
end)
it("should render child elements before children when TempConfig.newConnectionOrder is false", function()
local oldNewConnectionOrder = TempConfig.newConnectionOrder
TempConfig.newConnectionOrder = false
local function mapStateToProps(state)
return {
count = state.count,
}
end
local childWasRenderedFirst = false
local function ChildComponent(props)
if props.count > props.parentCount then
childWasRenderedFirst = true
end
end
local ConnectedChildComponent = connect(mapStateToProps)(ChildComponent)
local function ParentComponent(props)
return Roact.createElement(ConnectedChildComponent, {
parentCount = props.count,
})
end
local ConnectedParentComponent = connect(mapStateToProps)(ParentComponent)
local store = Rodux.Store.new(reducer)
local tree = Roact.createElement(StoreProvider, {
store = store,
}, {
parent = Roact.createElement(ConnectedParentComponent),
})
local handle = Roact.mount(tree)
store:dispatch({ type = "increment" })
store:flush()
store:dispatch({ type = "increment" })
store:flush()
Roact.unmount(handle)
expect(childWasRenderedFirst).to.equal(true)
TempConfig.newConnectionOrder = oldNewConnectionOrder
end)
end
@@ -0,0 +1,7 @@
local storeKey = require(script.Parent.storeKey)
local function getStore(componentInstance)
return componentInstance._context[storeKey]
end
return getStore
@@ -0,0 +1,62 @@
return function()
local Roact = require(script.Parent.Parent.Roact)
local Rodux = require(script.Parent.Parent.Rodux)
local StoreProvider = require(script.Parent.StoreProvider)
local getStore = require(script.Parent.getStore)
it("should return the store when present", function()
local function reducer()
return 0
end
local store = Rodux.Store.new(reducer)
local consumedStore = nil
local StoreConsumer = Roact.Component:extend("StoreConsumer")
function StoreConsumer:init()
consumedStore = getStore(self)
end
function StoreConsumer:render()
return nil
end
local tree = Roact.createElement(StoreProvider, {
store = store,
}, {
Consumer = Roact.createElement(StoreConsumer),
})
local handle = Roact.mount(tree)
expect(consumedStore).to.equal(store)
Roact.unmount(handle)
store:destruct()
end)
it("should return nil when the store is not present", function()
-- Use a non-nil value to know for sure if StoreConsumer:init was called
local consumedStore = 6
local StoreConsumer = Roact.Component:extend("StoreConsumer")
function StoreConsumer:init()
consumedStore = getStore(self)
end
function StoreConsumer:render()
return nil
end
local tree = Roact.createElement(StoreConsumer)
local handle = Roact.mount(tree)
expect(consumedStore).to.equal(nil)
Roact.unmount(handle)
end)
end
@@ -0,0 +1,13 @@
local StoreProvider = require(script.StoreProvider)
local connect = require(script.connect)
local getStore = require(script.getStore)
local TempConfig = require(script.TempConfig)
return {
StoreProvider = StoreProvider,
connect = connect,
UNSTABLE_getStore = getStore,
UNSTABLE_connect2 = connect,
TEMP_CONFIG = TempConfig,
}
@@ -0,0 +1,17 @@
local function join(...)
local result = {}
for i = 1, select("#", ...) do
local source = select(i, ...)
if source ~= nil then
for key, value in pairs(source) do
result[key] = value
end
end
end
return result
end
return join
@@ -0,0 +1,23 @@
local function shallowEqual(a, b)
if a == nil then
return b == nil
elseif b == nil then
return a == nil
end
for key, value in pairs(a) do
if value ~= b[key] then
return false
end
end
for key, value in pairs(b) do
if value ~= a[key] then
return false
end
end
return true
end
return shallowEqual
@@ -0,0 +1,45 @@
return function()
local shallowEqual = require(script.Parent.shallowEqual)
it("should compare dictionaries", function()
local a = {
a = "a",
b = {},
c = 6,
}
local b = {
b = a.b,
c = a.c,
a = a.a,
}
local c = {
b = {},
a = a.a,
c = a.c,
}
local d = {
a = a.a,
b = a.b,
c = a.c,
d = "hello",
}
expect(shallowEqual(a, a)).to.equal(true)
expect(shallowEqual(a, b)).to.equal(true)
expect(shallowEqual(a, c)).to.equal(false)
expect(shallowEqual(b, c)).to.equal(false)
expect(shallowEqual(a, d)).to.equal(false)
expect(shallowEqual(b, d)).to.equal(false)
end)
it("should handle nil for either argument", function()
local a = {}
expect(shallowEqual(nil, nil)).to.equal(true)
expect(shallowEqual(a, nil)).to.equal(false)
expect(shallowEqual(nil, a)).to.equal(false)
end)
end
@@ -0,0 +1,3 @@
local Symbol = require(script.Parent.Symbol)
return Symbol.named("RoduxStore")