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,113 @@
local Roact = require(script.Parent.Parent.Roact)
local storeKey = require(script.Parent.storeKey)
local function shallowEqual(a, b)
for key, value in pairs(a) do
if b[key] ~= value then
return false
end
end
for key, value in pairs(b) do
if a[key] ~= value then
return false
end
end
return true
end
--[[
Joins two tables together into a new table
]]
local function join(a, b)
local result = {}
for key, value in pairs(a) do
result[key] = value
end
for key, value in pairs(b) do
result[key] = value
end
return result
end
-- A version of 'error' that outputs over multiple lines
local function errorLines(...)
error(table.concat({...}, "\n"))
end
local function connect(mapStoreToProps)
local rootTrace = debug.traceback()
local mapConnect = function(store, props)
local result = mapStoreToProps(store, props)
if type(result) ~= "table" then
errorLines(
"mapStoreToProps must return a table! Check the function passed into 'connect' at:",
rootTrace
)
end
return result
end
return function(component)
if component == nil then
error("Expected component to be passed to connection, got nil.")
end
local name = ("Connection(%s)"):format(
tostring(component)
)
local Connection = Roact.Component:extend(name)
function Connection:init(props)
local store = self._context[storeKey]
if not store then
errorLines(
"Cannot initialize Roact-Rodux component without being a descendent of StoreProvider!",
("Tried to wrap component %q"):format(tostring(component)),
"Make sure there is a StoreProvider above this component in the tree."
)
end
self.store = store
self.state = {
storeProps = mapConnect(store, props),
}
end
function Connection:didMount()
self.eventHandle = self.store.changed:connect(function(state)
local storeProps = mapConnect(self.store, self.props)
if not shallowEqual(self.state.storeProps, storeProps) then
self:setState({
storeProps = storeProps
})
end
end)
end
function Connection:willUnmount()
self.eventHandle:disconnect()
end
function Connection:render()
local props = join(self.props, self.state.storeProps)
return Roact.createElement(component, props)
end
return Connection
end
end
return connect
@@ -0,0 +1,116 @@
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 function incrementReducer(state, action)
state = state or 0
if action.type == "increment" then
return state + 1
end
return state
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)
it("should successfully connect when mounted under a StoreProvider", function()
local store = Rodux.Store.new(incrementReducer)
local function SomeComponent(props)
return nil
end
local ConnectedSomeComponent = connect(function(store)
return {}
end)(SomeComponent)
local tree = Roact.createElement(StoreProvider, {
store = store,
}, {
Child = Roact.createElement(ConnectedSomeComponent),
})
local handle = Roact.mount(tree)
expect(handle).to.be.ok()
end)
it("should fail to mount without a StoreProvider", function()
local function SomeComponent(props)
return nil
end
local ConnectedSomeComponent = connect(function(store)
return {}
end)(SomeComponent)
local tree = Roact.createElement(ConnectedSomeComponent)
expect(function()
Roact.mount(tree)
end).to.throw()
end)
it("should trigger renders on store changes only with shallow differences", function()
local callCount = 0
local store = Rodux.Store.new(incrementReducer)
local function SomeComponent(props)
callCount = callCount + 1
return nil
end
local ConnectedSomeComponent = connect(function(store)
return {
value = store:getState()
}
end)(SomeComponent)
local tree = Roact.createElement(StoreProvider, {
store = store,
}, {
Child = Roact.createElement(ConnectedSomeComponent),
})
Roact.mount(tree)
-- Our component should render initially
expect(store:getState()).to.equal(0)
expect(callCount).to.equal(1)
store:dispatch({
type = "increment",
})
store:flush()
-- Our component should re-render, state is different.
expect(store:getState()).to.equal(1)
expect(callCount).to.equal(2)
store:dispatch({
type = "SOME_UNHANDLED_ACTION",
})
store:flush()
-- Our component should not re-render, state is the same!
expect(store:getState()).to.equal(1)
expect(callCount).to.equal(2)
end)
end
@@ -0,0 +1,183 @@
local Roact = require(script.Parent.Parent.Roact)
local storeKey = require(script.Parent.storeKey)
local shallowEqual = require(script.Parent.shallowEqual)
local join = require(script.Parent.join)
--[[
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)
return prevState.stateUpdater(nextProps, prevState)
end
function Connection:init()
self.store = self._context[storeKey]
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,
}
self.state.propsForChild = stateUpdater(self.props, self.state, mappedStoreState)
end
function Connection:didMount()
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: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,251 @@
return function()
local connect = require(script.Parent.connect2)
local StoreProvider = require(script.Parent.StoreProvider)
local Roact = require(script.Parent.Parent.Roact)
local Rodux = require(script.Parent.Parent.Rodux)
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)
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,11 @@
local StoreProvider = require(script.StoreProvider)
local connect = require(script.connect)
local connect2 = require(script.connect2)
local getStore = require(script.getStore)
return {
StoreProvider = StoreProvider,
connect = connect,
UNSTABLE_connect2 = connect2,
UNSTABLE_getStore = getStore,
}
@@ -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")