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,29 @@
local NavigationSymbol = require(script.Parent.NavigationSymbol)
local NONE_TOKEN = NavigationSymbol("NONE")
local INITIAL_ROUTE_TOKEN = NavigationSymbol("INITIAL_ROUTE")
local ORDER_TOKEN = NavigationSymbol("ORDER")
local HISTORY_TOKEN = NavigationSymbol("HISTORY")
--[[
BackBehavior provides shared constants that are used to configure back
action styles for different navigators. Note that not all routers support
all BackBehaviors and they will fall back to appropriate defaults for
those cases.
]]
local BackBehavior = {
None = NONE_TOKEN,
InitialRoute = INITIAL_ROUTE_TOKEN,
Order = ORDER_TOKEN,
History = HISTORY_TOKEN,
}
-- we are using this metatable to error when BackBehavior is indexed
-- with an unexpected key.
setmetatable(BackBehavior, {
__index = function(self, key)
error(("%q is not a valid member of BackBehavior"):format(tostring(key)), 2)
end,
})
return BackBehavior
@@ -0,0 +1,21 @@
local NavigationSymbol = require(script.Parent.NavigationSymbol)
local WILL_FOCUS_TOKEN = NavigationSymbol("WILL_FOCUS")
local DID_FOCUS_TOKEN = NavigationSymbol("DID_FOCUS")
local WILL_BLUR_TOKEN = NavigationSymbol("WILL_BLUR")
local DID_BLUR_TOKEN = NavigationSymbol("DID_BLUR")
local ACTION_TOKEN = NavigationSymbol("ACTION")
local REFOCUS_TOKEN = NavigationSymbol("REFOCUS")
--[[
Events provides shared constants that are used to register
listeners for different RoactNavigation UI state changes.
]]
return {
WillFocus = WILL_FOCUS_TOKEN,
DidFocus = DID_FOCUS_TOKEN,
WillBlur = WILL_BLUR_TOKEN,
DidBlur = DID_BLUR_TOKEN,
Action = ACTION_TOKEN,
Refocus = REFOCUS_TOKEN,
}
@@ -0,0 +1,71 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/62da341b672a83786b9c3a80c8a38f929964d7cc/packages/core/src/NavigationActions.ts
local NavigationSymbol = require(script.Parent.NavigationSymbol)
local BACK_TOKEN = NavigationSymbol("BACK")
local INIT_TOKEN = NavigationSymbol("INIT")
local NAVIGATE_TOKEN = NavigationSymbol("NAVIGATE")
local SET_PARAMS_TOKEN = NavigationSymbol("SET_PARAMS")
--[[
NavigationActions provides shared constants and methods to construct
actions that are dispatched to routers to cause a change in the route.
]]
local NavigationActions = {
Back = BACK_TOKEN,
Init = INIT_TOKEN,
Navigate = NAVIGATE_TOKEN,
SetParams = SET_PARAMS_TOKEN,
}
-- deviation: we using this metatable to error when NavigationActions is indexed
-- with an unexpected key.
setmetatable(NavigationActions, {
__index = function(self, key)
error(("%q is not a valid member of NavigationActions"):format(tostring(key)), 2)
end,
})
-- Navigate back in the history (temporally).
function NavigationActions.back(payload)
local data = payload or {}
return {
type = BACK_TOKEN,
key = data.key,
immediate = data.immediate,
}
end
-- Initialize the navigation history if not already defined.
function NavigationActions.init(payload)
local data = payload or {}
return {
type = INIT_TOKEN,
params = data.params,
}
end
-- Navigate to an existing or new route.
function NavigationActions.navigate(payload)
local data = payload or {}
return {
type = NAVIGATE_TOKEN,
routeName = data.routeName,
params = data.params,
action = data.action,
key = data.key,
}
end
-- Swap out the params for an existing route, matched by the given key.
function NavigationActions.setParams(payload)
local data = payload or {}
return {
type = SET_PARAMS_TOKEN,
preserveFocus = true,
key = data.key,
params = data.params,
}
end
return NavigationActions
@@ -0,0 +1,15 @@
-- Taken from Roact.Symbol and modified to produce exact string names
-- to allow for serialization/pathing.
return function (name)
assert(type(name) == "string", "Symbols must be created using a string name!")
local self = newproxy(true)
-- Unlike Symbols in Roact, we need the exact names.
getmetatable(self).__tostring = function()
return name
end
return self
end
@@ -0,0 +1,241 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/62da341b672a83786b9c3a80c8a38f929964d7cc/packages/core/src/StateUtils.js
local Cryo = require(script.Parent.Parent.Cryo)
--[[
StateUtils provides utilities to read and write standard route data.
Routes have the following general structure:
{
index = <required integer: current active route in list>,
routes = [
{
routeName = <required string: navigation name for route>,
key = <unique string: identifier for this route>,
params = <optional dictionary: params for screen>,
action = <optional dictionary: sub-action to be run by child routers>,
},
...
]
}
This structure is independent of the notion of stack, tab, drawer, or any
other kind of navigation. It simply represents a list of pages and their
parameters. Different kinds of routers can treat the data in their own way.
]]
local StateUtils = {}
-- Get the route matching the given key. Returns nil if no match is found.
function StateUtils.get(state, key)
assert(type(state) == "table", "state must be a table")
assert(type(key) == "string", "key must be a string")
for _, route in ipairs(state.routes) do
if route.key == key then
return route
end
end
return nil
end
-- Get the index of the route matching the given key. Returns nil if no match is found.
function StateUtils.indexOf(state, key)
assert(type(state) == "table", "state must be a table")
assert(type(key) == "string", "key must be a string")
for index, route in ipairs(state.routes) do
if route.key == key then
return index
end
end
-- deviation: returning nil instead of -1
return nil
end
-- Returns true if a route exists matching the given key, false otherwise.
function StateUtils.has(state, key)
assert(type(state) == "table", "state must be a table")
assert(type(key) == "string", "key must be a string")
for _, route in ipairs(state.routes) do
if route.key == key then
return true
end
end
return false
end
-- Push a new route into the navigation state. Makes the pushed route active.
function StateUtils.push(state, route)
assert(type(state) == "table", "state must be a table")
assert(type(route) == "table", "route must be a table")
assert(StateUtils.indexOf(state, route.key) == nil,
("should not push route with duplicated key %s"):format(route.key))
local routes = Cryo.List.join(state.routes, { route })
return Cryo.Dictionary.join(state, {
index = #routes,
routes = routes,
})
end
-- Pop the top-most route from the navigation state (NOT the active route).
-- Makes the new top-most route active.
function StateUtils.pop(state)
assert(type(state) == "table", "state must be a table")
if state.index <= 1 then
-- [Note]: Over-popping does not throw error. Instead, it will be no-op.
return state
end
local routes = Cryo.List.removeIndex(state.routes, #state.routes)
return Cryo.Dictionary.join(state, {
index = #routes,
routes = routes,
})
end
-- Sets the active route to match the given index.
function StateUtils.jumpToIndex(state, index)
assert(type(state) == "table", "state must be a table")
assert(type(index) == "number", "index must be a number")
if index == state.index then
return state
end
assert(state.routes[index] ~= nil, ("invalid index %d to jump to"):format(index))
return Cryo.Dictionary.join(state, {
index = index,
})
end
-- Sets the active route to match the given key.
function StateUtils.jumpTo(state, key)
assert(type(state) == "table", "state must be a table")
assert(type(key) == "string", "key must be a string")
local index = StateUtils.indexOf(state, key)
assert(index ~= nil, ('attempt to jump to unknown key "%s"'):format(key))
return StateUtils.jumpToIndex(state, index)
end
-- Sets the active route to the previous route in the list.
function StateUtils.back(state)
assert(type(state) == "table", "state must be a table")
local index = state.index - 1
if not state.routes[index] then
return state
end
return StateUtils.jumpToIndex(state, index)
end
-- Sets the active route to the next route in the list.
function StateUtils.forward(state)
assert(type(state) == "table", "state must be a table")
local index = state.index + 1
if not state.routes[index] then
return state
end
return StateUtils.jumpToIndex(state, index)
end
-- Replace the route matching the given key. Sets the active route to the
-- newly replaced entry. Prunes the old entries that follow the replaced one.
function StateUtils.replaceAndPrune(state, key, route)
assert(type(state) == "table", "state must be a table")
assert(type(key) == "string", "key must be a string")
assert(type(route) == "table", "route must be a table")
local index = StateUtils.indexOf(state, key)
local replaced = StateUtils.replaceAtIndex(state, index, route)
return Cryo.Dictionary.join(replaced, {
routes = { unpack(replaced.routes, 1, index) }
})
end
-- Replace the route matching the given key without pruning the following routes.
-- The active route will be updated to match the newly replaced one unless
-- preserveIndex is true.
function StateUtils.replaceAt(state, key, route, preserveIndex)
assert(type(state) == "table", "state must be a table")
assert(type(key) == "string", "key must be a string")
assert(type(route) == "table", "route must be a table")
assert(preserveIndex == nil or type(preserveIndex) == "boolean",
"preserveIndex must be nil or a boolean")
local index = StateUtils.indexOf(state, key)
local nextIndex = preserveIndex and state.index or index
local nextState = StateUtils.replaceAtIndex(state, index, route)
nextState.index = nextIndex
return nextState
end
-- Replace the route at the given index. Updates the active route to point to
-- the replaced entry.
function StateUtils.replaceAtIndex(state, index, route)
assert(type(state) == "table", "state must be a table")
assert(type(index) == "number", "index must be a number")
assert(type(route) == "table", "route must be a table")
assert(state.routes[index] ~= nil,
("invalid index %d for replacing route %s"):format(index, route.key))
if state.routes[index] == route and index == state.index then
return state
end
local routes = Cryo.List.join(state.routes)
routes[index] = route
return Cryo.Dictionary.join(state, {
index = index,
routes = routes,
})
end
-- Wipe away the existing routes and replace them with new routes.
-- Sets the active route to the provided index (if provided), otherwise
-- sets the active route to the last one in the list.
function StateUtils.reset(state, routes, index)
assert(type(state) == "table", "state must be a table")
assert(type(routes) == "table" and #routes > 0, "invalid routes to replace")
assert(index == nil or type(index) == "number", "index must be a number or nil")
local nextIndex = not index and #routes or index
-- Bail out without replacing IFF index and routes all match
if #state.routes == #routes and state.index == nextIndex then
local routesAreEqual = true
for i = 1, #routes, 1 do
if state.routes[i] ~= routes[i] then
routesAreEqual = false
break
end
end
if routesAreEqual then
return state
end
end
assert(routes[nextIndex] ~= nil, ("invalid index %d to reset"):format(nextIndex))
return Cryo.Dictionary.join(state, {
index = nextIndex,
routes = routes,
})
end
return StateUtils
@@ -0,0 +1,19 @@
return function()
local BackBehavior = require(script.Parent.Parent.BackBehavior)
describe("BackBehavior token tests", function()
it("should return same object for each token for multiple calls", function()
expect(BackBehavior.None).to.equal(BackBehavior.None)
expect(BackBehavior.InitialRoute).to.equal(BackBehavior.InitialRoute)
expect(BackBehavior.Order).to.equal(BackBehavior.Order)
expect(BackBehavior.History).to.equal(BackBehavior.History)
end)
it("should return matching string names for symbols", function()
expect(tostring(BackBehavior.None)).to.equal("NONE")
expect(tostring(BackBehavior.InitialRoute)).to.equal("INITIAL_ROUTE")
expect(tostring(BackBehavior.Order)).to.equal("ORDER")
expect(tostring(BackBehavior.History)).to.equal("HISTORY")
end)
end)
end
@@ -0,0 +1,23 @@
return function()
local Events = require(script.Parent.Parent.Events)
describe("Events token tests", function()
it("should return same object for each token for multiple calls", function()
expect(Events.WillFocus).to.equal(Events.WillFocus)
expect(Events.DidFocus).to.equal(Events.DidFocus)
expect(Events.WillBlur).to.equal(Events.WillBlur)
expect(Events.DidBlur).to.equal(Events.DidBlur)
expect(Events.Action).to.equal(Events.Action)
expect(Events.Refocus).to.equal(Events.Refocus)
end)
it("should return matching string names for symbols", function()
expect(tostring(Events.WillFocus)).to.equal("WILL_FOCUS")
expect(tostring(Events.DidFocus)).to.equal("DID_FOCUS")
expect(tostring(Events.WillBlur)).to.equal("WILL_BLUR")
expect(tostring(Events.DidBlur)).to.equal("DID_BLUR")
expect(tostring(Events.Action)).to.equal("ACTION")
expect(tostring(Events.Refocus)).to.equal("REFOCUS")
end)
end)
end
@@ -0,0 +1,73 @@
return function()
local NavigationActions = require(script.Parent.Parent.NavigationActions)
it("throws when indexing an unknown field", function()
expect(function()
return NavigationActions.foo
end).to.throw("\"foo\" is not a valid member of NavigationActions")
end)
describe("NavigationActions token tests", function()
it("should return same object for each token for multiple calls", function()
expect(NavigationActions.Back).to.equal(NavigationActions.Back)
expect(NavigationActions.Init).to.equal(NavigationActions.Init)
expect(NavigationActions.Navigate).to.equal(NavigationActions.Navigate)
expect(NavigationActions.SetParams).to.equal(NavigationActions.SetParams)
end)
it("should return matching string names for symbols", function()
expect(tostring(NavigationActions.Back)).to.equal("BACK")
expect(tostring(NavigationActions.Init)).to.equal("INIT")
expect(tostring(NavigationActions.Navigate)).to.equal("NAVIGATE")
expect(tostring(NavigationActions.SetParams)).to.equal("SET_PARAMS")
end)
end)
describe("NavigationActions function tests", function()
it("should return a back action with matching data for a call to back()", function()
local backTable = NavigationActions.back({
key = "the_key",
immediate = true,
})
expect(backTable.type).to.equal(NavigationActions.Back)
expect(backTable.key).to.equal("the_key")
expect(backTable.immediate).to.equal(true)
end)
it("should return an init action with matching data for call to init()", function()
local initTable = NavigationActions.init({
params = "foo",
})
expect(initTable.type).to.equal(NavigationActions.Init)
expect(initTable.params).to.equal("foo")
end)
it("should return a navigate action with matching data for call to navigate()", function()
local navigateTable = NavigationActions.navigate({
routeName = "routeName",
params = "foo",
action = "action",
key = "key",
})
expect(navigateTable.type).to.equal(NavigationActions.Navigate)
expect(navigateTable.routeName).to.equal("routeName")
expect(navigateTable.params).to.equal("foo")
expect(navigateTable.action).to.equal("action")
expect(navigateTable.key).to.equal("key")
end)
it("should return a set params action with matching data for call to setParams()", function()
local setParamsTable = NavigationActions.setParams({
key = "key",
params = "foo",
})
expect(setParamsTable.type).to.equal(NavigationActions.SetParams)
expect(setParamsTable.key).to.equal("key")
expect(setParamsTable.params).to.equal("foo")
end)
end)
end
@@ -0,0 +1,75 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/72e8160537954af40f1b070aa91ef45fc02bba69/packages/core/src/__tests__/NavigationActions.test.js
return function()
local NavigationActions = require(script.Parent.Parent.NavigationActions)
local expectDeepEqual = require(script.Parent.Parent.utils.expectDeepEqual)
describe("generic navigation actions", function()
local params = { foo = "bar" }
local navigateAction = NavigationActions.navigate({ routeName = "another" })
it("exports back action and type", function()
expectDeepEqual(NavigationActions.back(), { type = NavigationActions.Back })
expectDeepEqual(
NavigationActions.back({ key = "test" }),
{
type = NavigationActions.Back,
key = "test",
}
)
end)
it("exports init action and type", function()
expectDeepEqual(NavigationActions.init(), { type = NavigationActions.Init })
expectDeepEqual(
NavigationActions.init({ params = params }),
{
type = NavigationActions.Init,
params = params,
}
)
end)
it("exports navigate action and type", function()
expectDeepEqual(
NavigationActions.navigate({ routeName = "test" }),
{
type = NavigationActions.Navigate,
routeName = "test",
}
)
expectDeepEqual(
NavigationActions.navigate({
routeName = "test",
params = params,
action = navigateAction,
}),
{
type = NavigationActions.Navigate,
routeName = "test",
params = params,
action = {
type = NavigationActions.Navigate,
routeName = "another",
},
}
)
end)
it("exports setParams action and type", function()
expectDeepEqual(
NavigationActions.setParams({
key = "test",
params = params,
}),
{
type = NavigationActions.SetParams,
key = "test",
preserveFocus = true,
params = params,
}
)
end)
end)
end
@@ -0,0 +1,271 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/9b55493e7662f4d54c21f75e53eb3911675f61bc/packages/core/src/__tests__/NavigationFocusEvents.test.js
local RunService = game:GetService("RunService")
return function()
local root = script.Parent.Parent
local Packages = root.Parent
local TabRouter = require(root.routers.TabRouter)
local createAppContainer = require(root.createAppContainer)
local createNavigator = require(root.navigators.createNavigator)
local Events = require(root.Events)
local NavigationActions = require(root.NavigationActions)
local createSpy = require(root.utils.createSpy)
local Cryo = require(Packages.Cryo)
local Roact = require(Packages.Roact)
-- deviation: utility function moved out of test scope because
-- it is shared across both tests
local function createTestNavigator(routeConfigMap, config)
config = config or {}
local router = TabRouter(routeConfigMap, config)
return createNavigator(
function(props)
local navigation = props.navigation
local descriptors = props.descriptors
local children = Cryo.List.foldLeft(navigation.state.routes, function(acc, route)
local Comp = descriptors[route.key].getComponent()
acc[route.key] = Roact.createElement(Comp, {
key = route.key,
navigation = descriptors[route.key].navigation
})
return acc
end, {})
return Roact.createFragment(children)
end,
router,
config
)
end
local function waitUntil(predicate, timeout)
timeout = timeout or 1
local waitedTime = 0
while waitedTime < timeout and not predicate() do
waitedTime = waitedTime + RunService.Heartbeat:Wait()
end
end
-- deviation: utility function moved out of test scope because
-- it is shared across both tests
local function createComponent(focusCallback, blurCallback)
local TestComponent = Roact.Component:extend("TestComponent")
function TestComponent:didMount()
local navigation = self.props.navigation
self.focusSub = navigation.addListener(Events.WillFocus, focusCallback)
self.blurSub = navigation.addListener(Events.WillBlur, blurCallback)
end
function TestComponent:willUnmount()
self.focusSub.remove()
self.blurSub.remove()
end
function TestComponent:render()
return nil
end
return TestComponent
end
it("fires focus and blur events in root navigator", function()
local firstFocusCallback = createSpy()
local firstBlurCallback = createSpy()
local secondFocusCallback = createSpy()
local secondBlurCallback = createSpy()
local thirdFocusCallback = createSpy()
local thirdBlurCallback = createSpy()
local fourthFocusCallback = createSpy()
local fourthBlurCallback = createSpy()
local Navigator = createAppContainer(
createTestNavigator({
{ first = createComponent(firstFocusCallback.value, firstBlurCallback.value) },
{ second = createComponent(secondFocusCallback.value, secondBlurCallback.value) },
{ third = createComponent(thirdFocusCallback.value, thirdBlurCallback.value) },
{ fourth = createComponent(fourthFocusCallback.value, fourthBlurCallback.value) },
})
)
local dispatch
local element = Roact.createElement(Navigator, {
externalDispatchConnector = function(currentDispatch)
dispatch = currentDispatch
return function ()
dispatch = nil
end
end
})
Roact.mount(element)
waitUntil(function()
return firstFocusCallback.callCount > 0
end)
expect(firstFocusCallback.callCount).to.equal(1)
expect(firstBlurCallback.callCount).to.equal(0)
expect(secondFocusCallback.callCount).to.equal(0)
expect(secondBlurCallback.callCount).to.equal(0)
expect(thirdFocusCallback.callCount).to.equal(0)
expect(thirdBlurCallback.callCount).to.equal(0)
expect(fourthFocusCallback.callCount).to.equal(0)
expect(fourthBlurCallback.callCount).to.equal(0)
dispatch(NavigationActions.navigate({ routeName = 'second' }))
waitUntil(function()
return firstBlurCallback.callCount > 0
end)
expect(firstBlurCallback.callCount).to.equal(1)
expect(secondFocusCallback.callCount).to.equal(1)
dispatch(NavigationActions.navigate({ routeName = 'fourth' }))
waitUntil(function()
return secondBlurCallback.callCount > 0
end)
expect(firstFocusCallback.callCount).to.equal(1)
expect(firstBlurCallback.callCount).to.equal(1)
expect(secondFocusCallback.callCount).to.equal(1)
expect(secondBlurCallback.callCount).to.equal(1)
expect(thirdFocusCallback.callCount).to.equal(0)
expect(thirdBlurCallback.callCount).to.equal(0)
expect(fourthFocusCallback.callCount).to.equal(1)
expect(fourthBlurCallback.callCount).to.equal(0)
end)
it('fires focus and blur events in nested navigator', function()
local firstFocusCallback = createSpy()
local firstBlurCallback = createSpy()
local secondFocusCallback = createSpy()
local secondBlurCallback = createSpy()
local thirdFocusCallback = createSpy()
local thirdBlurCallback = createSpy()
local fourthFocusCallback = createSpy()
local fourthBlurCallback = createSpy()
local Navigator = createAppContainer(
createTestNavigator({
{ first = createComponent(firstFocusCallback.value, firstBlurCallback.value) },
{ second = createComponent(secondFocusCallback.value, secondBlurCallback.value) },
{
nested = createTestNavigator({
{ third = createComponent(thirdFocusCallback.value, thirdBlurCallback.value) },
{ fourth = createComponent(fourthFocusCallback.value, fourthBlurCallback.value) },
})
},
})
)
local dispatch
local element = Roact.createElement(Navigator, {
externalDispatchConnector = function(currentDispatch)
dispatch = currentDispatch
return function ()
dispatch = nil
end
end
})
Roact.mount(element)
waitUntil(function()
return firstFocusCallback.callCount > 0
end)
expect(thirdFocusCallback.callCount).to.equal(0)
expect(firstFocusCallback.callCount).to.equal(1)
dispatch(NavigationActions.navigate({ routeName = 'nested' }))
waitUntil(function()
return thirdFocusCallback.callCount > 0
end)
expect(firstFocusCallback.callCount).to.equal(1)
expect(fourthFocusCallback.callCount).to.equal(0)
expect(thirdFocusCallback.callCount).to.equal(1)
dispatch(NavigationActions.navigate({ routeName = 'second' }))
waitUntil(function()
return secondFocusCallback.callCount > 0
end)
expect(thirdFocusCallback.callCount).to.equal(1)
expect(secondFocusCallback.callCount).to.equal(1)
expect(fourthBlurCallback.callCount).to.equal(0)
dispatch(NavigationActions.navigate({ routeName = 'nested' }))
waitUntil(function()
return thirdFocusCallback.callCount > 1
end)
expect(firstBlurCallback.callCount).to.equal(1)
expect(secondBlurCallback.callCount).to.equal(1)
expect(thirdFocusCallback.callCount).to.equal(2)
expect(fourthFocusCallback.callCount).to.equal(0)
dispatch(NavigationActions.navigate({ routeName = 'third' }))
expect(fourthBlurCallback.callCount).to.equal(0)
expect(thirdFocusCallback.callCount).to.equal(2)
dispatch(NavigationActions.navigate({ routeName = 'first' }))
waitUntil(function()
return firstFocusCallback.callCount > 1
end)
expect(firstFocusCallback.callCount).to.equal(2)
expect(thirdBlurCallback.callCount).to.equal(2)
dispatch(NavigationActions.navigate({ routeName = 'fourth' }))
waitUntil(function()
return fourthFocusCallback.callCount > 0
end)
expect(fourthFocusCallback.callCount).to.equal(1)
expect(thirdBlurCallback.callCount).to.equal(2)
expect(firstBlurCallback.callCount).to.equal(2)
dispatch(NavigationActions.navigate({ routeName = 'third' }))
waitUntil(function()
return thirdFocusCallback.callCount > 2
end)
expect(thirdFocusCallback.callCount).to.equal(3)
expect(fourthBlurCallback.callCount).to.equal(1)
-- Make sure nothing else has changed
expect(firstFocusCallback.callCount).to.equal(2)
expect(firstBlurCallback.callCount).to.equal(2)
expect(secondFocusCallback.callCount).to.equal(1)
expect(secondBlurCallback.callCount).to.equal(1)
expect(thirdFocusCallback.callCount).to.equal(3)
expect(thirdBlurCallback.callCount).to.equal(2)
expect(fourthFocusCallback.callCount).to.equal(1)
expect(fourthBlurCallback.callCount).to.equal(1)
end)
end
@@ -0,0 +1,22 @@
return function()
local NavigationSymbol = require(script.Parent.Parent.NavigationSymbol)
it("should give an opaque object", function()
local symbol = NavigationSymbol("foo")
expect(symbol).to.be.a("userdata")
end)
it("should coerce to the given name", function()
local symbol = NavigationSymbol("foo")
expect(tostring(symbol)).to.equal("foo")
end)
it("should be unique when constructed", function()
local symbolA = NavigationSymbol("abc")
local symbolB = NavigationSymbol("abc")
expect(symbolA).never.to.equal(symbolB)
end)
end
@@ -0,0 +1,630 @@
return function()
local StateUtils = require(script.Parent.Parent.StateUtils)
describe("StateUtils.get tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.get(nil, "key")
end).to.throw()
end)
it("should assert if key is not a string", function()
expect(function()
StateUtils.get({}, 5)
end).to.throw()
end)
it("should return nil if key is not found in routes", function()
local result = StateUtils.get({
index = 1,
routes = {
{
routeName = "foo",
key = "foo-1",
},
},
}, "key")
expect(result).to.equal(nil)
end)
it("should return route if key is found in routes", function()
local result = StateUtils.get({
index = 1,
routes = {
{
routeName = "foo",
key = "foo-1",
}
},
}, "foo-1")
expect(result.routeName).to.equal("foo")
expect(result.key).to.equal("foo-1")
end)
end)
describe("StateUtils.indexOf tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.indexOf(nil, "key")
end).to.throw()
end)
it("should assert if key is not a string", function()
expect(function()
StateUtils.indexOf({}, 5)
end).to.throw()
end)
it("should return nil if key is not found in routes", function()
local result = StateUtils.indexOf({
index = 1,
routes = {
{
routeName = "foo",
key = "foo-1",
}
},
}, "key")
expect(result).to.equal(nil)
end)
it("should return index if key is found in routes", function()
local result = StateUtils.indexOf({
index = 1,
routes = {
{
routeName = "foo",
key = "foo-1",
},
{
routeName = "foo2",
key = "foo-2",
}
},
}, "foo-2")
expect(result).to.equal(2)
end)
end)
describe("StateUtils.has tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.has(nil, "key")
end).to.throw()
end)
it("should assert if key is not a string", function()
expect(function()
StateUtils.has({}, 5)
end).to.throw()
end)
it("should return false if key is not in routes", function()
local result = StateUtils.has({
index = 1,
routes = {
{
routeName = "foo",
key = "foo-1",
}
}
}, "key")
expect(result).to.equal(false)
end)
it("should return true if key is found in routes", function()
local result = StateUtils.has({
index = 1,
routes = {
{
routeName = "foo",
key = "foo-1",
}
}
}, "foo-1")
expect(result).to.equal(true)
end)
end)
describe("StateUtils.push tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.push(nil, {})
end).to.throw()
end)
it("should assert if route is not a table", function()
expect(function()
StateUtils.push({}, 5)
end).to.throw()
end)
it("should assert if route.key is already present", function()
expect(function()
StateUtils.push({
index = 1,
routes = {
{
routeName = "foo",
key = "foo-1",
}
}
}, {
routeName = "foo",
key = "foo-1",
})
end).to.throw()
end)
it("should insert new route if it doesn't exist", function()
local newState = StateUtils.push({
index = 1,
routes = {
{
routeName = "first",
key = "foo-1",
},
},
}, {
routeName = "second",
key = "foo-2",
})
expect(newState.index).to.equal(2)
expect(#newState.routes).to.equal(2)
expect(newState.routes[newState.index].key).to.equal("foo-2")
expect(newState.routes[newState.index].routeName).to.equal("second")
end)
end)
describe("StateUtils.pop tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.pop(nil)
end).to.throw()
end)
it("should return existing state if routes is empty", function()
local initialState = {
index = 0,
routes = {},
}
local newState = StateUtils.pop(initialState)
expect(newState).to.equal(initialState)
end)
it("should remove top route if popping with more than one route", function()
local initialState = {
index = 2,
routes = {
{ routeName = "route", key = "route-1", },
{ routeName = "route", key = "route-2", },
},
}
local newState = StateUtils.pop(initialState)
expect(newState.index).to.equal(1)
expect(#newState.routes).to.equal(1)
expect(newState.routes[1].key).to.equal("route-1")
end)
end)
describe("StateUtils.jumpToIndex tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.jumpToIndex(nil, 0)
end).to.throw()
end)
it("should assert if index is not a number", function()
expect(function()
StateUtils.jumpToIndex({}, "foo")
end).to.throw()
end)
it("should assert if index does not match a route", function()
expect(function()
StateUtils.jumpToIndex({
index = 1,
routes = { { routeName = "first", key = "first-1" } }
}, 5)
end).to.throw()
end)
it("should return original state if index matches current", function()
local initialState = {
index = 1,
routes = { { routeName = "one", key = "1" } }
}
local newState = StateUtils.jumpToIndex(initialState, 1)
expect(newState).to.equal(initialState)
end)
it("should return updated state if index differs", function()
local initialState = {
index = 1,
routes = {
{ routeName = "route", key = "route-1" },
{ routeName = "route", key = "route-2" },
},
}
local newState = StateUtils.jumpToIndex(initialState, 2)
expect(newState.index).to.equal(2)
end)
end)
describe("StateUtils.jumpTo tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.jumpTo(nil, "key")
end).to.throw()
end)
it("should assert if key is not a string", function()
expect(function()
StateUtils.jumpTo({}, 0)
end).to.throw()
end)
it("should return original state if key is already active route", function()
local initialState = {
index = 1,
routes = {
{ routeName = "route", key = "key-1" },
{ routeName = "route", key = "key-2" },
}
}
local newState = StateUtils.jumpTo(initialState, "key-1")
expect(newState).to.equal(initialState)
end)
it("should return state with new active route if key is not active", function()
local initialState = {
index = 1,
routes = {
{ routeName = "route", key = "key-1" },
{ routeName = "route", key = "key-2" },
}
}
local newState = StateUtils.jumpTo(initialState, "key-2")
expect(newState.index).to.equal(2)
end)
end)
describe("StateUtils.back tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.back(nil)
end).to.throw()
end)
it("should return original state if route for new index does not exist", function()
local initialState = {
index = 1,
routes = {
{ routeName = "route", key = "key-1" },
}
}
local newState = StateUtils.back(initialState)
expect(newState).to.equal(initialState)
end)
it("should remove top state if there is somewhere to go", function()
local initialState = {
index = 2,
routes = {
{ routeName = "route", key = "key-1" },
{ routeName = "route", key = "key-2" },
}
}
local newState = StateUtils.back(initialState)
expect(newState.index).to.equal(1)
end)
end)
describe("StateUtils.forward tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.forward(nil)
end).to.throw()
end)
it("should not walk off the end of the route list", function()
local initialState = {
index = 1,
routes = {
{ routeName = "route", key = "key-1" },
}
}
local newState = StateUtils.forward(initialState)
expect(newState).to.equal(initialState)
end)
it("should move to next route if available", function()
local initialState = {
index = 1,
routes = {
{ routeName = "route", key = "key-1" },
{ routeName = "route", key = "key-2" },
}
}
local newState = StateUtils.forward(initialState)
expect(newState.index).to.equal(2)
end)
end)
describe("StateUtils.replaceAndPrune tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.replaceAndPrune(nil, "key", {})
end).to.throw()
end)
it("should assert if key is not a string", function()
expect(function()
StateUtils.replaceAndPrune({}, 0, {})
end).to.throw()
end)
it("should assert if route is not a table", function()
expect(function()
StateUtils.replaceAndPrune({}, "key", 0)
end).to.throw()
end)
it("should replace matching route and prune following routes", function()
local initialState = {
index = 2,
routes = {
{ routeName = "route", key = "key-1" },
{ routeName = "route", key = "key-2" },
}
}
local newState = StateUtils.replaceAndPrune(initialState, "key-1", {
routeName = "newRoute", key = "key-3"
})
expect(newState.index).to.equal(1)
expect(#newState.routes).to.equal(1)
expect(newState.routes[1].routeName).to.equal("newRoute")
expect(newState.routes[1].key).to.equal("key-3")
end)
end)
describe("StateUtils.replaceAt tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.replaceAt(nil, "key", {}, false)
end).to.throw()
end)
it("should assert if key is not a string", function()
expect(function()
StateUtils.replaceAt({}, 0, {}, false)
end).to.throw()
end)
it("should assert if route is not a table", function()
expect(function()
StateUtils.replaceAt({}, "key", 0, false)
end).to.throw()
end)
it("should assert if preserveIndex is not a boolean", function()
expect(function()
StateUtils.replaceAt({}, "key", {}, 0)
end).to.throw()
end)
it("should replace matching route, not prune, and update index", function()
local initialState = {
index = 2,
routes = {
{ routeName = "route", key = "key-1" },
{ routeName = "route", key = "key-2" },
}
}
local newState = StateUtils.replaceAt(initialState, "key-1", {
routeName = "newRoute", key = "key-3"
}, false)
expect(newState.index).to.equal(1)
expect(#newState.routes).to.equal(2)
expect(newState.routes[1].routeName).to.equal("newRoute")
expect(newState.routes[1].key).to.equal("key-3")
end)
it("should replace matching route, not prune, and preserve existing index", function()
local initialState = {
index = 2,
routes = {
{ routeName = "route", key = "key-1" },
{ routeName = "route", key = "key-2" },
}
}
local newState = StateUtils.replaceAt(initialState, "key-1", {
routeName = "newRoute", key = "key-3"
}, true)
expect(newState.index).to.equal(2)
expect(#newState.routes).to.equal(2)
expect(newState.routes[1].routeName).to.equal("newRoute")
expect(newState.routes[1].key).to.equal("key-3")
end)
end)
describe("StateUtils.replaceAtIndex tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.replaceAtIndex(nil, 0, {})
end).to.throw()
end)
it("should assert if index is not a number", function()
expect(function()
StateUtils.replaceAtIndex({}, nil, {})
end).to.throw()
end)
it("should assert if route is not a table", function()
expect(function()
StateUtils.replaceAtIndex({}, 5, nil)
end).to.throw()
end)
it("should assert if index does not exist", function()
expect(function()
StateUtils.replaceAtIndex({
index = 0,
routes = {}
}, 5, { routeName = "name", key = "key" })
end).to.throw()
end)
it("should return original state if inputs are same", function()
local testRoute = { routeName = "name", key = "key" }
local initialState = {
index = 1,
routes = { testRoute },
}
local newState = StateUtils.replaceAtIndex(initialState, 1, testRoute)
expect(newState).to.equal(initialState)
end)
it("should replace route at index if route is not equal", function()
local initialState = {
index = 1,
routes = {
{ routeName = "name", key = "key" }
},
}
local newState = StateUtils.replaceAtIndex(initialState, 1, {
routeName = "newName",
key = "key",
})
expect(newState.index).to.equal(1)
expect(#newState.routes).to.equal(1)
expect(newState.routes[1].routeName).to.equal("newName")
expect(newState.routes[1].key).to.equal("key")
end)
it("should update index, if new index differs but route does not", function()
local testRoute = { routeName = "name", key = "key-2" }
local initialState = {
index = 1,
routes = {
{ routeName = "name", key = "key-1" },
testRoute,
}
}
local newState = StateUtils.replaceAtIndex(initialState, 2, testRoute)
expect(newState).never.to.equal(initialState)
expect(newState.index).to.equal(2)
end)
end)
describe("StateUtils.reset tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.reset(nil, {}, 0)
end).to.throw()
end)
it("should assert if routes is not a table", function()
expect(function()
StateUtils.reset({}, nil, 0)
end).to.throw()
end)
it("should assert if index is not a number", function()
expect(function()
StateUtils.reset({}, {}, "foo")
end).to.throw()
end)
-- the test does not seem to match with the name
it("should NOT assert if index is nil", function()
expect(function()
StateUtils.reset({}, {})
end).to.throw()
end)
it("should return original state if index matches and all routes are same objects", function()
local route1 = { routeName = "route1", key = "route-1" }
local route2 = { routeName = "route2", key = "route-2" }
local initialState = {
index = 2,
routes = { route1, route2 },
}
local newState = StateUtils.reset(initialState, {
route1,
route2,
}, 2)
expect(newState).to.equal(initialState)
end)
it("should update state if index is not specified and old index is not last route", function()
local route1 = { routeName = "route1", key = "route-1" }
local route2 = { routeName = "route2", key = "route-2" }
local initialState = {
index = 1,
routes = { route1, route2 },
}
local newState = StateUtils.reset(initialState, {
route1,
route2,
})
expect(newState).never.to.equal(initialState)
expect(newState.index).to.equal(2)
end)
it("should update state if index matches but routes differ", function()
local route1 = { routeName = "route1", key = "route-1" }
local route2 = { routeName = "route2", key = "route-2" }
local initialState = {
index = 1,
routes = { route1, route2 },
}
local newState = StateUtils.reset(initialState, {
route1,
{ routeName = "route3", key = "route-3" },
}, 1)
expect(newState).never.to.equal(initialState)
expect(#newState.routes).to.equal(2)
expect(newState.index).to.equal(1)
expect(newState.routes[2].routeName).to.equal("route3")
expect(newState.routes[2].key).to.equal("route-3")
end)
end)
end
@@ -0,0 +1,500 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/62da341b672a83786b9c3a80c8a38f929964d7cc/packages/core/src/__tests__/NavigationStateUtils.test.js
return function()
local StateUtils = require(script.Parent.Parent.StateUtils)
local utils = script.Parent.Parent.utils
local expectDeepEqual = require(utils.expectDeepEqual)
local routeName = "Anything"
describe("StateUtils", function()
describe("get", function()
it("gets route", function()
local state = {
index = 1,
routes = {
{
key = "a",
routeName = routeName,
},
},
}
expectDeepEqual(
StateUtils.get(state, "a"),
{
key = "a",
routeName = routeName,
}
)
end)
it("returns null when getting an unknown route", function()
local state = {
index = 1,
routes = {
{
key = "a",
routeName = routeName,
},
},
}
expect(StateUtils.get(state, "b")).to.equal(nil)
end)
end)
describe("indexOf", function()
it("gets route index", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
expect(StateUtils.indexOf(state, "a")).to.equal(1)
expect(StateUtils.indexOf(state, "b")).to.equal(2)
end)
-- deviation(will not fix): it is preferable to return `nil` as it's
-- more common so there is less chance to surprise the consumer of
-- that function
itSKIP("returns -1 when getting an unknown route index", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
},
isTransitioning = false,
}
expect(StateUtils.indexOf(state, "b")).to.equal(-1)
end)
end)
it("has a route", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
expect(StateUtils.has(state, "b")).to.equal(true)
expect(StateUtils.has(state, "c")).to.equal(false)
end)
describe("push", function()
it("pushes a route", function()
local state = {
index = 1,
routes = {{ key = "a", routeName = routeName }},
isTransitioning = false,
}
local newState = {
index = 2,
isTransitioning = false,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
}
expectDeepEqual(
StateUtils.push(state, { key = "b", routeName = routeName }),
newState
)
end)
it("does not push duplicated route", function()
local state = {
index = 1,
routes = {{ key = "a", routeName = routeName }},
isTransitioning = false,
}
expect(function()
StateUtils.push(state, { key = "a", routeName = routeName })
end).to.throw("should not push route with duplicated key a")
end)
end)
describe("pop", function()
it("pops route", function()
local state = {
index = 2,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
local newState = {
index = 1,
routes = {{ key = "a", routeName = routeName }},
isTransitioning = false,
}
expectDeepEqual(StateUtils.pop(state), newState)
end)
it("does not pop route if not applicable with single route config", function()
local state = {
index = 1,
routes = {{ key = "a", routeName = routeName }},
isTransitioning = false,
}
expectDeepEqual(
StateUtils.pop(state),
state
)
end)
it("does not pop route if not applicable with multiple route config", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
};
expectDeepEqual(StateUtils.pop(state), state)
end)
end)
describe("jumpToIndex", function()
it("jumps to new index", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
local newState = {
index = 2,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
expectDeepEqual(
StateUtils.jumpToIndex(state, 1),
state
)
expectDeepEqual(
StateUtils.jumpToIndex(state, 2),
newState
)
end)
it("throws if jumps to invalid index", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
expect(function()
StateUtils.jumpToIndex(state, 3)
end).to.throw("invalid index 3 to jump to")
end)
end)
describe("jumpTo", function()
it("jumps to the current key", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
expectDeepEqual(StateUtils.jumpTo(state, "a"), state)
end)
it("jumps to new key", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
local newState = {
index = 2,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
expectDeepEqual(
StateUtils.jumpTo(state, "b"),
newState
)
end)
it("throws if jumps to invalid key", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
expect(function()
StateUtils.jumpTo(state, "c")
end).to.throw("attempt to jump to unknown key \"c\"")
end)
end)
describe("back", function()
it("move backwards", function()
local state = {
index = 2,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
local newState = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
expectDeepEqual(StateUtils.back(state), newState)
end)
it("does not move backwards when the active route is the first", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
expect(StateUtils.back(state)).to.equal(state)
end)
end)
describe("forward", function()
it("move forwards", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
local newState = {
index = 2,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
expectDeepEqual(StateUtils.forward(state), newState)
end)
it("does not move forward when active route is already the top-most", function()
local state = {
index = 2,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
expectDeepEqual(StateUtils.forward(state), state)
end)
end)
describe("replace", function()
it("Replaces by key", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
local newState = {
index = 2,
routes = {
{ key = "a", routeName = routeName },
{ key = "c", routeName = routeName },
},
isTransitioning = false,
}
expectDeepEqual(
StateUtils.replaceAt(state, "b", { key = "c", routeName = routeName }),
newState
)
end)
it("Replaces by index", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
local newState = {
index = 2,
routes = {
{ key = "a", routeName = routeName },
{ key = "c", routeName = routeName },
},
isTransitioning = false,
}
expectDeepEqual(
StateUtils.replaceAtIndex(state, 2, { key = "c", routeName = routeName }),
newState
)
end)
it("Returns the state with updated index if route is unchanged but index changes", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
expectDeepEqual(
StateUtils.replaceAtIndex(state, 2, state.routes[2]),
{
index = 2,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
)
end)
end)
describe("reset", function()
it("Resets routes", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
local newState = {
index = 2,
routes = {
{ key = "x", routeName = routeName },
{ key = "y", routeName = routeName },
},
isTransitioning = false,
}
expectDeepEqual(
StateUtils.reset(state, {
{ key = "x", routeName = routeName },
{ key = "y", routeName = routeName },
}),
newState
)
end)
it("throws when attempting to set empty state", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
expect(function()
StateUtils.reset(state, {})
end).to.throw("invalid routes to replace")
end)
it("Resets routes with index", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
local newState = {
index = 1,
routes = {
{ key = "x", routeName = routeName },
{ key = "y", routeName = routeName },
},
isTransitioning = false,
}
expectDeepEqual(
StateUtils.reset(state, {
{ key = "x", routeName = routeName },
{ key = "y", routeName = routeName },
}, 1),
newState
)
end)
it("throws when attempting to set an out of range route index", function()
local state = {
index = 1,
routes = {
{ key = "a", routeName = routeName },
{ key = "b", routeName = routeName },
},
isTransitioning = false,
}
expect(function()
StateUtils.reset(state, {
{ key = "x", routeName = routeName },
{ key = "y", routeName = routeName },
}, 100)
end).to.throw("invalid index 100 to reset")
end)
end)
end)
end
@@ -0,0 +1,136 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Roact)
local NavigationActions = require(script.Parent.Parent.NavigationActions)
local createAppContainer = require(script.Parent.Parent.createAppContainer)
local createRobloxSwitchNavigator = require(script.Parent.Parent.navigators.createRobloxSwitchNavigator)
it("should be a function", function()
expect(type(createAppContainer)).to.equal("function")
end)
it("should return a valid component when mounting a switch navigator", function()
local TestNavigator = createRobloxSwitchNavigator({
{ Foo = function() end },
})
local TestApp = createAppContainer(TestNavigator)
local element = Roact.createElement(TestApp)
local instance = Roact.mount(element)
Roact.unmount(instance)
end)
it("should throw when navigator has both navigation and container props", function()
local TestAppComponent = Roact.Component:extend("TestAppComponent")
TestAppComponent.router = {}
function TestAppComponent:render() end
local element = Roact.createElement(createAppContainer(TestAppComponent), {
navigation = {},
somePropThatShouldNotBeHere = true,
})
local status, err = pcall(function()
Roact.mount(element)
end)
expect(status).to.equal(false)
expect(string.find(err, "This navigator has both 'navigation' and container props.")).to.never.equal(nil)
end)
it("should throw when not passed a table for AppComponent", function()
local TestAppComponent = 5
local status, err = pcall(function()
createAppContainer(TestAppComponent)
end)
expect(status).to.equal(false)
expect(string.find(err, "AppComponent must be a navigator or a stateful Roact " ..
"component with a 'router' field")).to.never.equal(nil)
end)
it("should throw when passed a stateful component without router field", function()
local TestAppComponent = Roact.Component:extend("TestAppComponent")
local status, err = pcall(function()
createAppContainer(TestAppComponent)
end)
expect(status).to.equal(false)
expect(string.find(err, "AppComponent must be a navigator or a stateful Roact " ..
"component with a 'router' field")).to.never.equal(nil)
end)
it("should accept actions from externalDispatchConnector", function()
local TestNavigator = createRobloxSwitchNavigator({
{ Foo = function() end },
})
local registeredCallback = nil
local externalDispatchConnector = function(rnCallback)
registeredCallback = rnCallback
return function()
registeredCallback = nil
end
end
local element = Roact.createElement(createAppContainer(TestNavigator), {
externalDispatchConnector = externalDispatchConnector,
})
local instance = Roact.mount(element)
expect(type(registeredCallback)).to.equal("function")
-- Make sure it processes action
local result = registeredCallback(NavigationActions.navigate({
routeName = "Foo",
}))
expect(result).to.equal(true)
local failResult = registeredCallback(NavigationActions.navigate({
routeName = "Bar", -- should fail because not a valid route
}))
expect(failResult).to.equal(false)
Roact.unmount(instance)
expect(registeredCallback).to.equal(nil)
end)
it("should correctly pass screenProps to pages", function()
local passedScreenProps = nil
local extractedValue1 = nil
local extractedMissingValue1 = nil
local extractedMissingValue2 = nil
local testScreenProps = {
MyKey1 = "MyValue1",
}
local TestNavigator = createRobloxSwitchNavigator({
{
Foo = function(props)
-- doing this in render is an abuse, but it's just a test
passedScreenProps = props.navigation.getScreenProps()
extractedValue1 = props.navigation.getScreenProps("MyKey1")
extractedMissingValue1 = props.navigation.getScreenProps("MyMissingKey", 5)
extractedMissingValue2 = props.navigation.getScreenProps("MyMissingKey")
end,
},
})
local TestApp = createAppContainer(TestNavigator)
local element = Roact.createElement(TestApp, {
screenProps = testScreenProps,
})
local instance = Roact.mount(element)
expect(passedScreenProps).to.equal(testScreenProps)
expect(extractedValue1).to.equal("MyValue1")
expect(extractedMissingValue1).to.equal(5)
expect(extractedMissingValue2).to.equal(nil)
Roact.unmount(instance)
end)
end
@@ -0,0 +1,122 @@
return function()
local getChildNavigation = require(script.Parent.Parent.getChildNavigation)
it("should return nil if there is no route matching requested key", function()
local testNavigation = {
state = {
routes = {
{ key = "a" }
}
}
}
local childNav = getChildNavigation(testNavigation, "invalid_child", function()
return testNavigation
end)
expect(childNav).to.equal(nil)
end)
it("should return cached child if its state is a top-level route", function()
local testNavigation = {
state = {
routes = {
{ key = "a" }
},
},
}
testNavigation._childrenNavigation = {
a = {
state = testNavigation.state.routes[1]
}
}
local childNav = getChildNavigation(testNavigation, "a", function()
return testNavigation
end)
expect(childNav).to.equal(testNavigation._childrenNavigation.a)
end)
it("should update cache and return new data when child's state has changed", function()
local testNavigation = {
state = {
routes = {
{ key = "a", routeName = "a" },
{ key = "b", routeName = "b" },
},
index = 1,
},
router = {
getComponentForRouteName = function(routeName)
return function() end
end,
getActionCreators = function() end,
},
}
local oldStateA = {
isFirstRouteInParent = function()
return true
end,
state = {
routes = {
{ key = "a", routeName = "a" },
{ key = "b", routeName = "b" },
},
index = 2,
},
}
testNavigation._childrenNavigation = {
a = oldStateA,
}
local childNav = getChildNavigation(testNavigation, "a", function()
return testNavigation
end)
expect(childNav).to.equal(testNavigation._childrenNavigation["a"])
expect(childNav.state).to.equal(testNavigation.state.routes[1])
expect(type(childNav.getParam)).to.equal("function")
end)
it("should create a new entry if cached child does not exist yet", function()
local testNavigation = {
state = {
routes = {
{ key = "a", routeName = "a", params = { a = 1 } },
{ key = "b", routeName = "b" },
},
index = 1,
},
router = {
getComponentForRouteName = function(routeName)
return function() end
end,
getActionCreators = function() end,
},
addListener = function()
return {
remove = function() end
}
end,
isFocused = function()
return true
end,
}
local childNav = getChildNavigation(testNavigation, "a", function()
return testNavigation
end)
expect(testNavigation._childrenNavigation["a"]).to.never.equal(nil)
expect(childNav).to.equal(testNavigation._childrenNavigation["a"])
expect(childNav.isFocused()).to.equal(true)
expect(childNav.getParam("a", 0)).to.equal(1)
expect(childNav.getParam("b", 0)).to.equal(0)
end)
end
@@ -0,0 +1,60 @@
return function()
local getChildRouter = require(script.Parent.Parent.getChildRouter)
it("should throw if router is not a table", function()
local status, err = pcall(function()
getChildRouter(5, "myRoute")
end)
expect(status).to.equal(false)
expect(string.find(err, "router must be a table")).to.never.equal(nil)
end)
it("should throw if routeName is not a string", function()
local status, err = pcall(function()
getChildRouter({}, 5)
end)
expect(status).to.equal(false)
expect(string.find(err, "routeName must be a string")).to.never.equal(nil)
end)
it("should return child router if found", function()
local childRouter = {}
local result = getChildRouter({
childRouters = {
myRoute = childRouter,
}
}, "myRoute")
expect(result).to.equal(childRouter)
end)
it("should look up component router if no child router is found", function()
local component = { router = {} }
local result = getChildRouter({
getComponentForRouteName = function(routeName)
if routeName == "myRoute" then
return component
else
return nil
end
end
}, "myRoute")
expect(result).to.equal(component.router)
end)
it("should throw if no child routers are specified and getComponentForRouteName is not a function", function()
local status, err = pcall(function()
getChildRouter({
getComponentForRouteName = 5
}, "myRoute")
end)
expect(status).to.equal(false)
expect(string.find(err, "router.getComponentForRouteName must be a function if no child routers are specified")
).to.never.equal(nil)
end)
end
@@ -0,0 +1,64 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/f10543f9fcc0f347c9d23aeb57616fd0f21cd4e3/packages/core/src/__tests__/getChildrenNavigationCache.test.js
return function()
local getChildrenNavigationCache = require(script.Parent.Parent.getChildrenNavigationCache)
it("should return empty table if navigation arg not provided", function()
expect(getChildrenNavigationCache()._childrenNavigation).to.equal(nil)
end)
it("should populate navigation._childrenNavigation as a side-effect", function()
local navigation = { state = {} }
local result = getChildrenNavigationCache(navigation)
expect(result).to.never.equal(nil)
expect(navigation._childrenNavigation).to.equal(result)
end)
it("should delete children cache keys that are no longer valid", function()
local navigation = {
state = {
routes = {
{ key = "one" },
{ key = "two" },
{ key = "three" },
}
},
_childrenNavigation = {
one = {},
two = {},
three = {},
four = {},
}
}
local result = getChildrenNavigationCache(navigation)
expect(result.one).to.never.equal(nil)
expect(result.two).to.never.equal(nil)
expect(result.three).to.never.equal(nil)
expect(result.four).to.equal(nil)
end)
it("should not delete children cache keys if in transitioning state", function()
local navigation = {
state = {
routes = {
{ key = "one" },
{ key = "two" },
{ key = "three" },
},
isTransitioning = true,
},
_childrenNavigation = {
one = {},
two = {},
three = {},
four = {},
}
}
local result = getChildrenNavigationCache(navigation)
expect(result.one).to.never.equal(nil)
expect(result.two).to.never.equal(nil)
expect(result.three).to.never.equal(nil)
expect(result.four).to.never.equal(nil)
end)
end
@@ -0,0 +1,54 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/f10543f9fcc0f347c9d23aeb57616fd0f21cd4e3/packages/core/src/__tests__/getEventManager.test.js
return function()
local root = script.Parent.Parent
local getEventManager = require(root.getEventManager)
local Events = require(root.Events)
local createSpy = require(root.utils.createSpy)
local TARGET = "target"
it("calls listeners to emitted event", function()
local eventManager = getEventManager(TARGET)
local callback = createSpy()
eventManager.addListener(Events.DidFocus, callback.value)
eventManager.emit(Events.DidFocus)
expect(callback.callCount).to.equal(1)
end)
it("does not call listeners connected to a different event", function()
local eventManager = getEventManager(TARGET)
local callback = createSpy()
eventManager.addListener(Events.DidFocus, callback.value)
eventManager.emit("didBlur")
expect(callback.callCount).to.equal(0)
end)
it("does not call removed listeners", function()
local eventManager = getEventManager(TARGET)
local callback = createSpy()
local remove = eventManager.addListener(Events.DidFocus, callback.value).remove
eventManager.emit(Events.DidFocus)
expect(callback.callCount).to.equal(1)
remove()
eventManager.emit(Events.DidFocus)
expect(callback.callCount).to.equal(1)
end)
it("calls the listeners with the given payload", function()
local eventManager = getEventManager(TARGET)
local callback = createSpy()
eventManager.addListener(Events.DidFocus, callback.value)
local payload = { foo = 0 }
eventManager.emit(Events.DidFocus, payload)
callback:assertCalledWithDeepEqual(payload)
end)
end
@@ -0,0 +1,94 @@
return function()
local Events = require(script.Parent.Parent.Events)
local getNavigation = require(script.Parent.Parent.getNavigation)
local function makeTestBundle(testState)
testState = testState or {
routes = {
{ key = "a" }
},
index = 1,
}
local testActions = {}
local bundle = {
testActions = testActions,
testState = testState,
testRouter = {
getActionCreators = function()
return testActions
end
},
testDispatch = function() end,
testActionSubscribers = {},
testGetScreenProps = function() end,
}
function bundle.testGetCurrentNavigation()
return bundle.navigation
end
bundle.navigation = getNavigation(
bundle.testRouter,
bundle.testState,
bundle.testDispatch,
bundle.testActionSubscribers,
bundle.testGetScreenProps,
bundle.testGetCurrentNavigation
)
return bundle
end
it("should build out correct public props", function()
local bundle = makeTestBundle()
expect(bundle.navigation.actions).to.equal(bundle.testActions)
expect(bundle.navigation.router).to.equal(bundle.testRouter)
expect(bundle.navigation.state).to.equal(bundle.testState)
expect(bundle.navigation.dispatch).to.equal(bundle.testDispatch)
expect(bundle.navigation.getScreenProps).to.equal(bundle.testGetScreenProps)
expect(#bundle.navigation._childrenNavigation).to.equal(0)
end)
describe("isFocused tests", function()
it("should return focused=true for child key matching index", function()
local bundle = makeTestBundle()
expect(bundle.navigation.isFocused("a")).to.equal(true)
end)
it("should return focused=false for child key not matching index", function()
local bundle = makeTestBundle({
routes = {
{ key = "a" },
{ key = "b" },
},
index = 2,
})
expect(bundle.navigation.isFocused("a")).to.equal(false)
end)
it("should return focused=true if no child key provided (parent always focused)", function()
local bundle = makeTestBundle()
expect(bundle.navigation.isFocused()).to.equal(true)
end)
end)
describe("addListener tests", function()
it("should short-circuit subscriptions for non-Action events", function()
local bundle = makeTestBundle()
local testHandler = function() end
bundle.navigation.addListener(Events.WillFocus, testHandler)
expect(bundle.testActionSubscribers[testHandler]).to.equal(nil)
end)
it("should add Action event handlers to actionSubscribers set", function()
local bundle = makeTestBundle()
local testHandler = function() end
bundle.navigation.addListener(Events.Action, testHandler)
expect(bundle.testActionSubscribers[testHandler]).to.equal(true)
end)
end)
end
@@ -0,0 +1,131 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/20e2625f351f90fadadbf98890270e43e744225b/packages/core/src/__tests__/getNavigation.test.js
return function()
local root = script.Parent.Parent
local getNavigation = require(root.getNavigation)
local NavigationActions = require(root.NavigationActions)
local createSpy = require(root.utils.createSpy)
it("getNavigation provides default action helpers", function()
local router = {
getActionCreators = function()
return {}
end,
getStateForAction = function(action, lastState)
return lastState or {}
end,
}
local dispatchSpy = createSpy()
local topNav = getNavigation(
router,
{},
dispatchSpy.value,
{},
function()
return {}
end,
function() end
)
topNav.navigate("GreatRoute")
expect(dispatchSpy.callCount).to.equal(1)
expect(dispatchSpy.values[1].type).to.equal(NavigationActions.Navigate)
expect(dispatchSpy.values[1].routeName).to.equal("GreatRoute")
end)
it("getNavigation provides router action helpers", function()
local router = {
getActionCreators = function()
return {
foo = function(bar)
return { type = "FooBarAction", bar = bar }
end,
}
end,
getStateForAction = function(action, lastState)
return lastState or {}
end,
}
local dispatchSpy = createSpy()
local topNav = nil
topNav = getNavigation(
router,
{},
dispatchSpy.value,
{},
function()
return {}
end,
function()
return topNav
end
)
topNav.foo("Great")
expect(dispatchSpy.callCount).to.equal(1)
expect(dispatchSpy.values[1].type).to.equal("FooBarAction")
expect(dispatchSpy.values[1].bar).to.equal("Great")
end)
it("getNavigation get child navigation with router", function()
local actionSubscribers = {}
local navigation = nil
local routerA = {
getActionCreators = function()
return {}
end,
getStateForAction = function(action, lastState)
return lastState or {}
end,
}
local router = {
childRouters = {
RouteA = routerA,
},
getActionCreators = function()
return {}
end,
getStateForAction = function(action, lastState)
return lastState or {}
end,
}
local initState = {
index = 0,
routes = {
{
key = "a",
routeName = "RouteA",
routes = {{ key = "c", routeName = "RouteC" }},
index = 0,
},
{ key = "b", routeName = "RouteB" },
},
}
local topNav = getNavigation(
router,
initState,
function() end,
actionSubscribers,
function()
return {}
end,
function()
return navigation
end
)
local childNavA = topNav.getChildNavigation("a")
expect(childNavA.router).to.equal(routerA)
end)
end
@@ -0,0 +1,147 @@
return function()
local root = script.Parent.Parent
local Packages = root.Parent
local RoactNavigation = require(root)
local Roact = require(Packages.Roact)
local StackPresentationStyle = require(root.views.RobloxStackView.StackPresentationStyle)
it("should return a function for createAppContainer", function()
expect(type(RoactNavigation.createAppContainer)).to.equal("function")
end)
it("should return a function for getNavigation", function()
expect(type(RoactNavigation.getNavigation)).to.equal("function")
end)
it("should return an appropriate table for Context", function()
expect(type(RoactNavigation.Context)).to.equal("table")
expect(type(RoactNavigation.Context.Provider)).to.equal("table")
expect(type(RoactNavigation.Context.Consumer)).to.equal("table")
end)
it("should return a Component for Provider", function()
expect(type(RoactNavigation.Provider)).to.equal("table")
end)
it("should return a Component for Consumer", function()
expect(type(RoactNavigation.Consumer)).to.equal("table")
end)
it("should return a function for withNavigation", function()
expect(type(RoactNavigation.withNavigation)).to.equal("function")
end)
it("should return a function for withNavigationFocus", function()
expect(type(RoactNavigation.withNavigationFocus)).to.equal("function")
end)
it("should return a function for createRobloxSwitchNavigator", function()
expect(type(RoactNavigation.createRobloxSwitchNavigator)).to.equal("function")
end)
it("should return a function for createRobloxStackNavigator", function()
expect(type(RoactNavigation.createRobloxStackNavigator)).to.equal("function")
end)
it("should return a function for createNavigator", function()
expect(type(RoactNavigation.createNavigator)).to.equal("function")
end)
it("should return a function for StackRouter", function()
expect(type(RoactNavigation.StackRouter)).to.equal("function")
end)
it("should return a function for SwitchRouter", function()
expect(type(RoactNavigation.SwitchRouter)).to.equal("function")
end)
it("should return a function for TabRouter", function()
expect(type(RoactNavigation.TabRouter)).to.equal("function")
end)
it("should return a table for Actions", function()
expect(type(RoactNavigation.Actions)).to.equal("table")
end)
it("should return a table for StackActions", function()
expect(type(RoactNavigation.StackActions)).to.equal("table")
end)
it("should return a table for SwitchActions", function()
expect(type(RoactNavigation.SwitchActions)).to.equal("table")
end)
it("should return a table for BackBehavior", function()
expect(type(RoactNavigation.BackBehavior)).to.equal("table")
end)
it("should return a table for Events", function()
expect(type(RoactNavigation.Events)).to.equal("table")
end)
it("should return a valid component for NavigationEvents", function()
local instance = Roact.mount(Roact.createElement(RoactNavigation.Provider, {
value = {
addListener = function()
return { remove = function() end }
end
}
}, {
Events = Roact.createElement(RoactNavigation.NavigationEvents),
}))
Roact.unmount(instance)
end)
it("should return StackPresentationStyle", function()
expect(RoactNavigation.StackPresentationStyle).to.equal(StackPresentationStyle)
end)
it("should return a valid component for SceneView", function()
expect(RoactNavigation.SceneView.render).never.to.equal(nil)
local instance = Roact.mount(Roact.createElement(RoactNavigation.SceneView, {
navigation = {},
component = function() end,
}))
Roact.unmount(instance)
end)
it("should return a valid component for RobloxSwitchView", function()
local testNavigation = {
state = {
routes = {
{ routeName = "Foo", key = "Foo", }
},
index = 1,
}
}
local instance = Roact.mount(Roact.createElement(RoactNavigation.RobloxSwitchView, {
descriptors = {
Foo = {
getComponent = function()
return function() end
end,
navigation = testNavigation,
}
},
navigation = testNavigation,
}))
Roact.unmount(instance)
end)
it("should return a function for createConfigGetter", function()
expect(type(RoactNavigation.createConfigGetter)).to.equal("function")
end)
it("should return a function for getScreenForRouteName", function()
expect(type(RoactNavigation.getScreenForRouteName)).to.equal("function")
end)
it("should return a function for validateRouteConfigMap", function()
expect(type(RoactNavigation.validateRouteConfigMap)).to.equal("function")
end)
it("should return a function for getActiveChildNavigationOptions", function()
expect(type(RoactNavigation.getActiveChildNavigationOptions)).to.equal("function")
end)
end
@@ -0,0 +1,300 @@
local Roact = require(script.Parent.Parent.Roact)
local Cryo = require(script.Parent.Parent.Cryo)
local NavigationActions = require(script.Parent.NavigationActions)
local Events = require(script.Parent.Events)
local NavigationContext = require(script.Parent.views.NavigationContext)
local getNavigation = require(script.Parent.getNavigation)
local validate = require(script.Parent.utils.validate)
local function validateProps(props)
if not props.navigation then
return
end
local errStr =
"This navigator has both 'navigation' and container props. " ..
"It is unclear if it should own its own state. Remove the " ..
"container props or don't pass a 'navigation' prop."
for key in pairs(props) do
validate(key == "screenProps" or key == "navigation", errStr)
end
end
--[[
Construct a container Roact component that will host the navigation hierarchy
specified by your main AppComponent. AppComponent must be a navigator created by
a Roact-Navigation helper function, or a stateful Roact component
If you are using a custom stateful Roact component, make sure to set the 'router'
field so that it can be hooked into the navigation system. You must also pass your
'navigation' prop to any child navigators.
Additional props:
renderLoading - Roact component to render while the app is loading.
externalDispatchConnector - Function that Roact Navigation can use to connect to
externally triggered navigation Actions. This is useful
for external UI or handling of the Android back button.
Ex:
local connector = function(rnDispatch)
-- You store rnDispatch and call it when you want to inject
-- an event from outside RN.
return function()
-- You disconnect rnDispatch when RN calls this.
end
end
...
Roact.createElement(MyRNAppContainer, {
externalDispatchConnector = connector,
})
]]
return function(AppComponent)
validate(type(AppComponent) == "table" and AppComponent.router ~= nil,
"AppComponent must be a navigator or a stateful Roact component with a 'router' field")
local containerName = string.format("NavigationContainer(%s)", tostring(AppComponent))
local NavigationContainer = Roact.Component:extend(containerName)
function NavigationContainer.getDerivedStateFromProps(nextProps)
validateProps(nextProps)
return nil
end
function NavigationContainer:init()
validateProps(self.props)
self._actionEventSubscribers = {}
self._initialAction = NavigationActions.init()
local initialNav = nil
local containerIsStateful = self:_isStateful()
if containerIsStateful and not self.props.persistenceKey then
initialNav = AppComponent.router.getStateForAction(self._initialAction)
end
self.state = {
nav = initialNav,
}
end
function NavigationContainer:_updateExternalDispatchConnector()
local externalDispatchConnector = self.props.externalDispatchConnector
if self._subs then
self._subs()
self._subs = nil
end
if externalDispatchConnector ~= nil then
self._subs = externalDispatchConnector(function(...)
if self._isMounted then
return self:dispatch(...)
end
-- External dispatch while we're not mounted gets dropped on floor.
return false
end)
end
end
function NavigationContainer:_renderLoading()
local renderLoading = self.props.renderLoading
if renderLoading then
return renderLoading()
else
return nil
end
end
function NavigationContainer:render()
local navigation = self.props.navigation
if self:_isStateful() then
local navState = self.state.nav
if not navState then
return self:_renderLoading()
end
if not self._navigation or self._navigation.state ~= navState then
self._navigation = getNavigation(
AppComponent.router,
navState,
function(...)
return self:dispatch(...)
end,
self._actionEventSubscribers,
function(...)
return self:_getScreenProps(...)
end,
function()
return self._navigation
end
)
end
navigation = self._navigation
end
validate(navigation ~= nil, "failed to get navigation")
return Roact.createElement(NavigationContext.Provider, {
value = navigation,
}, {
-- Provide navigation prop for top-level component so it doesn't have to connect.
AppComponent = Roact.createElement(AppComponent, Cryo.Dictionary.join(self.props, {
navigation = navigation,
}))
})
end
function NavigationContainer:didMount()
self._isMounted = true
self:_updateExternalDispatchConnector()
if not self:_isStateful() then
return
end
local action = self._initialAction
local startupState = self.state.nav
if not startupState then
startupState = AppComponent.router.getStateForAction(action)
end
local function dispatchActionEvents()
-- _actionEventSubscribers is a table(handler, true), e.g. a Set container
for subscriber in pairs(self._actionEventSubscribers) do
subscriber({
type = Events.Action,
action = action,
state = self.state.nav,
-- there is no lastState for initial mounting
})
end
end
if startupState ~= self.state.nav then
self:setState({
nav = startupState
})
end
-- This must be spawned until we get async setState callback handler in Roact
spawn(dispatchActionEvents)
end
function NavigationContainer:willUnmount()
self._isMounted = false
-- TODO: Disconnect from from URL listener once implemented
if self._subs then
self._subs()
self._subs = nil
end
end
function NavigationContainer:didUpdate(oldProps)
-- Clear cached _navState every time we update.
if self._navState == self.state.nav then
self._navState = nil
end
if self.props.externalDispatchConnector ~= oldProps.externalDispatchConnector then
self:_updateExternalDispatchConnector()
end
end
function NavigationContainer:_isStateful()
return not self.props.navigation
end
-- NOTE: Not implementing _validateProps; it is duplicate
-- NOTE: Not implementing _handleOpenURL; app should have a component
-- that transforms URLs into paths for AppContainer instead.
function NavigationContainer:_onNavigationStateChange(prevNav, nextNav, action)
local onNavigationStateChange = self.props.onNavigationStateChange
if type(onNavigationStateChange) == "function" then
onNavigationStateChange(prevNav, nextNav, action)
end
end
function NavigationContainer:_getScreenProps(propKey, defaultValue)
local screenProps = self.props.screenProps or {}
if propKey ~= nil then
return screenProps[propKey] or defaultValue
end
-- Legacy: return original table if no args provided
return screenProps
end
function NavigationContainer:dispatch(action)
if self.props.navigation then
return self.props.navigation.dispatch(action)
end
self._navState = self._navState or self.state.nav
local lastNavState = self._navState
validate(lastNavState ~= nil, "navState should be set in constructor if stateful")
local reducedState = AppComponent.router.getStateForAction(action, lastNavState)
local navState = reducedState
if not navState then
navState = lastNavState
end
local function dispatchActionEvents()
-- _actionEventSubscribers is a table(handler, true), e.g. a Set container
for subscriber in pairs(self._actionEventSubscribers) do
subscriber({
type = Events.Action,
action = action,
state = navState,
lastState = lastNavState,
})
end
end
if reducedState == nil then
-- Router returns nil when action has been handled and there is no state change.
-- dispatch() must return true whenever something has been handled.
dispatchActionEvents()
return true
end
if navState ~= lastNavState then
-- Update cache to ensure that subsequent calls do not discard this change
self._navState = navState
-- TODO: We have to dispatch events before or after setState (which mounts/unmounts components)
-- based upon the specific event type, to ensure that pages get them in the correct order...
self:setState({
nav = navState
})
-- Must be spawned until we get async setState callback handler in Roact.
spawn(function()
self:_onNavigationStateChange(lastNavState, navState, action)
dispatchActionEvents()
-- TODO: Add call to persist navigation state here, if we ever implement it.
end)
return true
end
spawn(dispatchActionEvents)
return false
end
return NavigationContainer
end
@@ -0,0 +1,137 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/72e8160537954af40f1b070aa91ef45fc02bba69/packages/core/src/getChildNavigation.js
local Cryo = require(script.Parent.Parent.Cryo)
local getEventManager = require(script.Parent.getEventManager)
local getChildRouter = require(script.Parent.getChildRouter)
local getNavigationActionCreators = require(script.Parent.routers.getNavigationActionCreators)
local getChildrenNavigationCache = require(script.Parent.getChildrenNavigationCache)
local function createParamGetter(route)
return function(paramName, defaultValue)
local params = route.params
if params and params[paramName] ~= nil then
return params[paramName]
else
return defaultValue
end
end
end
local function getChildNavigation(navigation, childKey, getCurrentParentNavigation)
local children = getChildrenNavigationCache(navigation)
local childRouteIndex = Cryo.List.findWhere(navigation.state.routes, function(route)
return route.key == childKey
end)
if not childRouteIndex then
return nil
end
local childRoute = navigation.state.routes[childRouteIndex]
local requestedChild = children[childKey]
if requestedChild and requestedChild.state == childRoute then
return requestedChild
end
local childRouter = getChildRouter(navigation.router, childRoute.routeName)
-- If the route has children that match our routes schema then get a reference
-- to the focused grandchild so we can pass the correct action creators to the
-- child router so that any action that depends on the child route will behave
-- as expected.
local focusedGrandChildRoute = nil
if childRoute.routes and type(childRoute.index) == "number" then
focusedGrandChildRoute = childRoute.routes[childRoute.index]
end
local childRouterActionCreators = childRouter and
childRouter.getActionCreators(focusedGrandChildRoute, childRoute.key) or {}
local actionCreators = Cryo.Dictionary.join(
navigation.actions or {},
navigation.router.getActionCreators(childRoute, navigation.state.key) or {},
childRouterActionCreators or {},
getNavigationActionCreators(childRoute) or {}
)
local actionHelpers = {}
for actionName, actionCreator in pairs(actionCreators) do
actionHelpers[actionName] = function(...)
local action = actionCreator(...)
return navigation.dispatch(action)
end
end
local isFirstRouteInParent = true;
local parentNavigation = getCurrentParentNavigation();
if parentNavigation then
isFirstRouteInParent = Cryo.List.find(parentNavigation.state.routes, childRoute) == 1;
end
if requestedChild and requestedChild.isFirstRouteInParent() == isFirstRouteInParent then
-- Update cache value for requestedChild because child's state has changed
children[childKey] = Cryo.Dictionary.join(requestedChild, actionHelpers, {
state = childRoute,
router = childRouter,
actions = actionCreators,
getParam = createParamGetter(childRoute),
})
return children[childKey]
else
-- No cached value for requestedChild. Create a new entry.
local childSubscriber = getEventManager(childKey)
children[childKey] = Cryo.Dictionary.join(actionHelpers, {
state = childRoute,
router = childRouter,
actions = actionCreators,
getParam = createParamGetter(childRoute),
getChildNavigation = function(grandChildKey)
return getChildNavigation(children[childKey], grandChildKey, function()
local nav = getCurrentParentNavigation()
return nav and nav.getChildNavigation(childKey) or nil
end)
end,
isFocused = function()
local currentNavigation = getCurrentParentNavigation()
if not currentNavigation then
return false
end
if not currentNavigation.isFocused() then
return false
end
local state = currentNavigation.state
local routes = state.routes
local index = state.index
if routes[index].key == childKey then
return true
end
return false
end,
isFirstRouteInParent = function()
return isFirstRouteInParent
end,
dispatch = navigation.dispatch,
getScreenProps = navigation.getScreenProps,
-- deviation: `dangerouslyGetParent` is renamed as private because
-- it is deprecated in latest react navigation
_dangerouslyGetParent = getCurrentParentNavigation,
addListener = childSubscriber.addListener,
emit = childSubscriber.emit,
})
return children[childKey]
end
end
return getChildNavigation
@@ -0,0 +1,22 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/62da341b672a83786b9c3a80c8a38f929964d7cc/packages/core/src/getChildRouter.ts
local validate = require(script.Parent.utils.validate)
return function(router, routeName)
validate(type(router) == "table", "router must be a table")
validate(type(routeName) == "string", "routeName must be a string")
if router.childRouters and router.childRouters[routeName] then
return router.childRouters[routeName]
end
validate(type(router.getComponentForRouteName) == "function",
"router.getComponentForRouteName must be a function if no child routers are specified")
local component = router.getComponentForRouteName(routeName)
-- deviation: functional components cannot be indexed in Lua
if type(component) == "table" then
return component.router
else
return nil
end
end
@@ -0,0 +1,28 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/62da341b672a83786b9c3a80c8a38f929964d7cc/packages/core/src/views/withNavigation.js
return function(navigation)
if not navigation then
return {}
end
if not navigation._childrenNavigation then
navigation._childrenNavigation = {}
end
local childrenNavigationCache = navigation._childrenNavigation
local childKeys = {}
for _, route in ipairs(navigation.state.routes or {}) do
childKeys[route.key] = true
end
if not navigation.state.isTransitioning then
for cacheKey, _ in pairs(childrenNavigationCache) do
if not childKeys[cacheKey] then
childrenNavigationCache[cacheKey] = nil
end
end
end
return navigation._childrenNavigation
end
@@ -0,0 +1,49 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/9b55493e7662f4d54c21f75e53eb3911675f61bc/packages/core/src/getEventManager.js
local root = script.Parent
local Packages = root.Parent
local Cryo = require(Packages.Cryo)
local function getEventManager(target)
local listeners = {}
local function removeListener(type, callback)
local callbacks = listeners[type] and listeners[type][target]
if not callbacks then
return
end
local index = table.find(callbacks, callback)
table.remove(callbacks, index)
end
local function addListener(type, callback)
listeners[type] = listeners[type] or {}
listeners[type][target] = listeners[type][target] or {}
table.insert(listeners[type][target], callback)
return {
remove = function()
removeListener(type, callback)
end,
}
end
return {
addListener = addListener,
emit = function(type, data)
local items = listeners[type] or {}
local callbacks = items[target] and Cryo.List.join({}, items[target])
if callbacks then
for _, callback in ipairs(callbacks) do
callback(data)
end
end
end,
}
end
return getEventManager
@@ -0,0 +1,63 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/72e8160537954af40f1b070aa91ef45fc02bba69/packages/core/src/getNavigation.js
local Cryo = require(script.Parent.Parent.Cryo)
local Events = require(script.Parent.Events)
local getNavigationActionCreators = require(script.Parent.routers.getNavigationActionCreators)
local getChildNavigation = require(script.Parent.getChildNavigation)
local getChildrenNavigationCache = require(script.Parent.getChildrenNavigationCache)
return function(router, state, dispatch, actionSubscribers, getScreenProps, getCurrentNavigation)
local actions = router.getActionCreators(state, nil)
local navigation = {
actions = actions,
router = router,
state = state,
dispatch = dispatch,
getScreenProps = getScreenProps,
-- deviation: `dangerouslyGetParent` is renamed as private because
-- it is deprecated in latest react navigation
_dangerouslyGetParent = function()
return nil
end,
isFirstRouteInParent = function()
return true
end,
_childrenNavigation = getChildrenNavigationCache(getCurrentNavigation()),
}
function navigation.getChildNavigation(childKey)
return getChildNavigation(navigation, childKey, getCurrentNavigation)
end
function navigation.isFocused(childKey)
local currentState = getCurrentNavigation().state
local routes = currentState.routes
local index = currentState.index
return childKey == nil or routes[index].key == childKey
end
function navigation.addListener(event, handler)
if event ~= Events.Action then
return { remove = function() end }
else
actionSubscribers[handler] = true
return {
remove = function()
actionSubscribers[handler] = nil
end
}
end
end
local actionCreators = Cryo.Dictionary.join(getNavigationActionCreators(navigation.state), actions)
for actionName, _ in pairs(actionCreators) do
navigation[actionName] = function(...)
navigation.dispatch(actionCreators[actionName](...))
end
end
return navigation
end
@@ -0,0 +1,54 @@
-- Generator information:
-- Human name: Roact Navigation
-- Variable name: RoactNavigation
-- Repo name: roact-navigation
local NavigationContext = require(script.views.NavigationContext)
return {
-- Navigation container construction
createAppContainer = require(script.createAppContainer),
getNavigation = require(script.getNavigation),
-- Context Access
Context = NavigationContext,
Provider = NavigationContext.Provider,
Consumer = NavigationContext.Consumer,
withNavigation = require(script.views.withNavigation),
withNavigationFocus = require(script.views.withNavigationFocus),
-- Navigators
createRobloxStackNavigator = require(script.navigators.createRobloxStackNavigator),
createRobloxSwitchNavigator = require(script.navigators.createRobloxSwitchNavigator),
createNavigator = require(script.navigators.createNavigator),
-- Routers
StackRouter = require(script.routers.StackRouter),
SwitchRouter = require(script.routers.SwitchRouter),
TabRouter = require(script.routers.TabRouter),
-- Navigation Actions
Actions = require(script.NavigationActions),
StackActions = require(script.routers.StackActions),
SwitchActions = require(script.routers.SwitchActions),
BackBehavior = require(script.BackBehavior),
-- Navigation Events
Events = require(script.Events),
NavigationEvents = require(script.views.NavigationEvents),
-- Additional Types
StackPresentationStyle = require(script.views.RobloxStackView.StackPresentationStyle),
-- Screen Views
SceneView = require(script.views.SceneView),
RobloxSwitchView = require(script.views.RobloxSwitchView),
RobloxStackView = require(script.views.RobloxStackView.StackView),
-- Utilities
createConfigGetter = require(script.routers.createConfigGetter),
getScreenForRouteName = require(script.routers.getScreenForRouteName),
validateRouteConfigMap = require(script.routers.validateRouteConfigMap),
getActiveChildNavigationOptions = require(script.utils.getActiveChildNavigationOptions),
}
@@ -0,0 +1,79 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local createNavigator = require(script.Parent.Parent.createNavigator)
local testRouter = {
getScreenOptions = function() return nil end,
}
it("should return a Roact component that exposes navigator fields", function()
local testComponentMounted = nil
local TestViewComponent = Roact.Component:extend("TestViewComponent")
function TestViewComponent:render() end
function TestViewComponent:didMount() testComponentMounted = true end
function TestViewComponent:willUnmount() testComponentMounted = false end
local testNavOptions = {}
local navigator = createNavigator(TestViewComponent, testRouter, {
navigationOptions = testNavOptions,
})
expect(navigator.render).to.be.a("function")
expect(navigator.router).to.equal(testRouter)
expect(navigator.navigationOptions).to.equal(testNavOptions)
local testNavigation = {
state = {
routes = {
{ routeName = "Foo", key = "Foo" },
},
index = 1
},
getChildNavigation = function() return nil end, -- stub
addListener = function() end,
}
-- Try to mount it
local instance = Roact.mount(Roact.createElement(navigator, {
navigation = testNavigation
}))
expect(testComponentMounted).to.equal(true)
Roact.unmount(instance)
expect(testComponentMounted).to.equal(false)
end)
it("should throw when trying to mount without navigation prop", function()
local TestViewComponent = function() end
local navigator = createNavigator(TestViewComponent, testRouter, {
navigationOptions = {}
})
expect(function()
Roact.mount(Roact.createElement(navigator))
end).to.throw()
end)
it("should throw when trying to mount without routes", function()
local TestViewComponent = function() end
local navigator = createNavigator(TestViewComponent, testRouter, {
navigationOptions = {}
})
local testNavigation = {
state = {
index = 1
},
getChildNavigation = function() return nil end, -- stub
}
expect(function()
Roact.mount(Roact.createElement(navigator, {
navigation = testNavigation
}))
end).to.throw()
end)
end
@@ -0,0 +1,40 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local createRobloxStackNavigator = require(script.Parent.Parent.createRobloxStackNavigator)
local getChildNavigation = require(script.Parent.Parent.Parent.getChildNavigation)
it("should return a mountable Roact component", function()
local navigator = createRobloxStackNavigator({
{ Foo = function() end },
})
local testNavigation = {
state = {
routes = {
{ routeName = "Foo", key = "Foo" },
},
index = 1
},
router = navigator.router
}
function testNavigation.getChildNavigation(childKey)
return getChildNavigation(testNavigation, childKey, function()
return testNavigation
end)
end
function testNavigation.addListener(symbol, callback)
return {
remove = function() end
}
end
local instance = Roact.mount(Roact.createElement(navigator, {
navigation = testNavigation
}))
Roact.unmount(instance)
end)
end
@@ -0,0 +1,40 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local createRobloxSwitchNavigator = require(script.Parent.Parent.createRobloxSwitchNavigator)
local getChildNavigation = require(script.Parent.Parent.Parent.getChildNavigation)
it("should return a mountable Roact component", function()
local navigator = createRobloxSwitchNavigator({
{ Foo = function() end },
})
local testNavigation = {
state = {
routes = {
{ routeName = "Foo", key = "Foo" },
},
index = 1
},
router = navigator.router
}
function testNavigation.getChildNavigation(childKey)
return getChildNavigation(testNavigation, childKey, function()
return testNavigation
end)
end
function testNavigation.addListener(symbol, callback)
return {
remove = function() end
}
end
local instance = Roact.mount(Roact.createElement(navigator, {
navigation = testNavigation
}))
Roact.unmount(instance)
end)
end
@@ -0,0 +1,40 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local createSwitchNavigator = require(script.Parent.Parent.createSwitchNavigator)
local getChildNavigation = require(script.Parent.Parent.Parent.getChildNavigation)
it("should return a mountable Roact component", function()
local navigator = createSwitchNavigator({
{ Foo = function() end },
})
local testNavigation = {
state = {
routes = {
{ routeName = "Foo", key = "Foo" },
},
index = 1
},
router = navigator.router
}
function testNavigation.getChildNavigation(childKey)
return getChildNavigation(testNavigation, childKey, function()
return testNavigation
end)
end
function testNavigation.addListener(symbol, callback)
return {
remove = function() end
}
end
local instance = Roact.mount(Roact.createElement(navigator, {
navigation = testNavigation
}))
Roact.unmount(instance)
end)
end
@@ -0,0 +1,105 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/9b55493e7662f4d54c21f75e53eb3911675f61bc/packages/core/src/navigators/createNavigator.js
local root = script.Parent.Parent
local Packages = root.Parent
local Roact = require(Packages.Roact)
local Cryo = require(Packages.Cryo)
local validate = require(root.utils.validate)
local NavigationFocusEvents = require(root.views.NavigationFocusEvents)
return function(navigatorViewComponent, router, navigationConfig)
local Navigator = Roact.Component:extend("Navigator")
-- These statics need to be accessible to routers
Navigator.router = router
Navigator.navigationOptions = navigationConfig.navigationOptions
-- deviation: no theme support
function Navigator:init()
local screenProps = self.props.screenProps
self.state = {
descriptors = {},
screenProps = screenProps,
-- deviation: no theme support
}
end
function Navigator.getDerivedStateFromProps(nextProps, prevState)
local prevDescriptors = prevState.descriptors
local navigation = nextProps.navigation
local screenProps = nextProps.screenProps
validate(navigation ~= nil, "The navigation prop is missing for this navigator. " ..
"In react-navigation v3 and v4 you must set up your app container directly. " ..
"More info: https://reactnavigation.org/docs/en/app-containers.html")
local routes = navigation.state.routes
validate(type(routes) == "table", 'No "routes" found in navigation state. ' ..
"Did you try to pass the navigation prop of a React component to a Navigator child? " ..
"See https://reactnavigation.org/docs/en/custom-navigators.html#navigator-navigation-prop")
local descriptors = Cryo.List.foldLeft(routes, function(descriptors, route)
if
prevDescriptors and
prevDescriptors[route.key] and
route == prevDescriptors[route.key].state and
screenProps == prevState.screenProps
-- deviation: no theme support
then
descriptors[route.key] = prevDescriptors[route.key]
else
local getComponent = function()
return router.getComponentForRouteName(route.routeName)
end
local childNavigation = navigation.getChildNavigation(route.key)
local options = router.getScreenOptions(childNavigation, screenProps)
descriptors[route.key] = {
key = route.key,
getComponent = getComponent,
options = options,
state = route,
navigation = childNavigation,
}
end
return descriptors
end, {})
return {
descriptors = descriptors,
screenProps = screenProps,
-- deviation: no theme support
}
end
-- deviation: no `componentDidUpdate` because no theme support
function Navigator:render()
local navigation = self.props.navigation
local screenProps = self.state.screenProps
local descriptors = self.state.descriptors
return Roact.createFragment({
Events = Roact.createElement(NavigationFocusEvents, {
navigation = navigation,
onEvent = function(target, type, data)
if descriptors[target] then
descriptors[target].navigation.emit(type, data);
end
end,
}),
View = Roact.createElement(navigatorViewComponent, Cryo.Dictionary.join(self.props, {
screenProps = screenProps,
navigation = navigation,
navigationConfig = navigationConfig,
descriptors = descriptors,
}))
})
end
return Navigator
end
@@ -0,0 +1,10 @@
local root = script.Parent.Parent
local createNavigator = require(script.Parent.createNavigator)
local StackRouter = require(root.routers.StackRouter)
local StackView = require(root.views.RobloxStackView.StackView)
return function(routeArray, stackConfig)
local router = StackRouter(routeArray, stackConfig)
return createNavigator(StackView, router, stackConfig or {})
end
@@ -0,0 +1,25 @@
local root = script.Parent.Parent
local createNavigator = require(script.Parent.createNavigator)
local SwitchRouter = require(root.routers.SwitchRouter)
local RobloxSwitchView = require(root.views.RobloxSwitchView)
--[[
Creates a navigator component that provides simple screen "switcher" behavior.
Each page is mutually exclusive and no transition animation is used.
Additional config options:
order : List<string>
Specifies the index order for page components, e.g. for use with tab bars.
keepVisitedScreensMounted : Boolean (false)
Set to true if you want to keep previously visited screens mounted for better performance.
resetOnBlur : Boolean (true)
Set to false if you want to preserve existing state for child navigators.
backBehavior : BackBehavior (None)
Set to BackBehavior.InitialRoute to allow a goBack() operation to return to the
initial route name. By default, the SwitchNavigator will not do anything on a back action.
]]
return function(routeArray, switchConfig)
local router = SwitchRouter(routeArray, switchConfig)
return createNavigator(RobloxSwitchView, router, switchConfig or {})
end
@@ -0,0 +1,14 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/1f5000e86bef5e4c8ee6fbeb25e3ca3eb8873ad0/packages/core/src/navigators/createSwitchNavigator.js
local root = script.Parent.Parent
local createNavigator = require(script.Parent.createNavigator)
local SwitchRouter = require(root.routers.SwitchRouter)
local SwitchView = require(root.views.SwitchView.SwitchView)
return function(routeArray, switchConfig)
if switchConfig == nil then
switchConfig = {}
end
local router = SwitchRouter(routeArray, switchConfig)
return createNavigator(SwitchView, router, switchConfig)
end
@@ -0,0 +1,74 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/62da341b672a83786b9c3a80c8a38f929964d7cc/packages/core/src/routers/StackActions.js
local root = script.Parent.Parent
local Packages = root.Parent
local Cryo = require(Packages.Cryo)
local NavigationSymbol = require(root.NavigationSymbol)
local POP_TOKEN = NavigationSymbol("POP")
local POP_TO_TOP_TOKEN = NavigationSymbol("POP_TO_TOP")
local PUSH_TOKEN = NavigationSymbol("PUSH")
local RESET_TOKEN = NavigationSymbol("RESET")
local REPLACE_TOKEN = NavigationSymbol("REPLACE")
local COMPLETE_TRANSITION_TOKEN = NavigationSymbol("COMPLETE_TRANSITION")
--[[
StackActions provides shared constants and methods to construct
actions that are dispatched to routers to cause a change in the route.
These actions are specific to Stack navigation. See NavigationActions
if you need to use more general APIs.
]]
local StackActions = {
Pop = POP_TOKEN,
PopToTop = POP_TO_TOP_TOKEN,
Push = PUSH_TOKEN,
Reset = RESET_TOKEN,
Replace = REPLACE_TOKEN,
CompleteTransition = COMPLETE_TRANSITION_TOKEN,
}
-- deviation: we using this metatable to error when StackActions is indexed
-- with an unexpected key.
setmetatable(StackActions, {
__index = function(self, key)
error(("%q is not a valid member of StackActions"):format(tostring(key)), 2)
end,
})
-- Pop the top-most item off the route stack, if any.
function StackActions.pop(payload)
return Cryo.Dictionary.join({ type = POP_TOKEN }, payload or {})
end
-- Pop all the items except the last one off the route stack.
function StackActions.popToTop(payload)
return Cryo.Dictionary.join({ type = POP_TO_TOP_TOKEN }, payload or {})
end
-- Push a new item onto the route stack.
function StackActions.push(payload)
return Cryo.Dictionary.join({ type = PUSH_TOKEN }, payload or {})
end
-- Reset the route stack and replace it with a new stack,
-- specified by a list of actions to be applied.
function StackActions.reset(payload)
return Cryo.Dictionary.join({ type = RESET_TOKEN }, payload or {})
end
-- Replace the route for the given key with a new route.
function StackActions.replace(payload)
return Cryo.Dictionary.join(
{ type = REPLACE_TOKEN, preserveFocus = true },
payload or {}
)
end
function StackActions.completeTransition(payload)
return Cryo.Dictionary.join(
{ type = COMPLETE_TRANSITION_TOKEN, preserveFocus = true },
payload or {}
)
end
return StackActions
@@ -0,0 +1,666 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/6390aacd07fd647d925dfec842a766c8aad5272f/packages/core/src/routers/TabRouter.js
local root = script.Parent.Parent
local Packages = root.Parent
local Cryo = require(Packages.Cryo)
local NavigationActions = require(root.NavigationActions)
local StackActions = require(script.Parent.StackActions)
local KeyGenerator = require(root.utils.KeyGenerator)
local StateUtils = require(root.StateUtils)
local getScreenForRouteName = require(script.Parent.getScreenForRouteName)
local createConfigGetter = require(script.Parent.createConfigGetter)
local validateRouteConfigArray = require(script.Parent.validateRouteConfigArray)
local validateRouteConfigMap = require(script.Parent.validateRouteConfigMap)
local validate = require(root.utils.validate)
local NavigationSymbol = require(root.NavigationSymbol)
local STACK_ROUTER_ROOT_KEY = "StackRouterRoot"
-- This symbol is used to differentiate if a router has a child router
-- or if is a regular Roact component. React-navigation does it by using
-- undefined vs. null (that's why we need)
local CHILD_IS_SCREEN = NavigationSymbol("CHILD_IS_SCREEN")
local defaultActionCreators = function() return {} end
local function behavesLikePushAction(action)
return action.type == NavigationActions.Navigate or
action.type == StackActions.Push
end
local function isResetToRootStack(action)
return action.type == StackActions.Reset and action.key == nil
end
local function mapToRouteName(element)
local routeName = next(element)
return routeName
end
local function foldToRoutes(routes, element, index)
local routeName, value = next(element)
routes[routeName] = value
return routes
end
return function(routeArray, config)
validateRouteConfigArray(routeArray)
config = config or {}
local routeConfigs = validateRouteConfigMap(
Cryo.List.foldLeft(routeArray, foldToRoutes, {})
)
local routeNames = config.order or Cryo.List.map(routeArray, mapToRouteName)
-- Loop through routes and find child routers
local childRouters = {}
for _, routeName in ipairs(routeNames) do
-- We're not using `getScreenForRouteName` here to preserve the lazy loading
-- behaviour of routes. This means that routes with child routers must be
-- defined using a component directly or with an object with a screen prop.
local routeConfig = routeConfigs[routeName]
local screen = (type(routeConfig) == "table" and routeConfig.screen)
and routeConfig.screen or routeConfig
if type(screen) == "table" and screen.router then
-- If it has a router it's a navigator.
childRouters[routeName] = screen.router
else
-- TODO: This is a hack to make this code behave like React-Navigation's usage of
-- null and undefined values for childRouters. We should come up with a better approach.
childRouters[routeName] = CHILD_IS_SCREEN
end
end
local initialRouteParams = config.initialRouteParams
local getCustomActionCreators = config.getCustomActionCreators or defaultActionCreators
local initialRouteName = config.initialRouteName or routeNames[1]
local initialChildRouter = childRouters[initialRouteName]
local initialRouteIndex = Cryo.List.find(routeNames, initialRouteName)
-- dump an error if initialRouteName is not in routes.
if initialRouteIndex == nil then
local availableRouteStr = ""
for _, name in ipairs(routeNames) do
availableRouteStr = availableRouteStr .. name .. ","
end
error(string.format("Invalid initialRouteName '%s'. Must be one of [%s]", initialRouteName, availableRouteStr), 2)
end
local function getInitialState(action)
local route = {}
local childRouter = childRouters[action.routeName]
-- This is a push-like action, and childRouter will be a router or null
-- if we are responsible for this routeName
if behavesLikePushAction(action) and childRouter ~= nil then
local childState = {}
-- The router is 'CHILD_IS_SCREEN' for normal leaf routes
if childRouter ~= CHILD_IS_SCREEN then
local childAction = action.action
or NavigationActions.init({ params = action.params })
childState = childRouter.getStateForAction(childAction)
end
return {
key = STACK_ROUTER_ROOT_KEY,
isTransitioning = false,
index = 1,
routes = {
Cryo.Dictionary.join({ params = action.params }, childState, {
key = action.key or KeyGenerator.generateKey(),
routeName = action.routeName,
})
}
}
end
-- we need to check if initialChildRouter is not CHILD_IS_SCREEN because
-- of the divergence with react-navigation.
if initialChildRouter ~= nil and initialChildRouter ~= CHILD_IS_SCREEN then
route = initialChildRouter.getStateForAction(NavigationActions.navigate({
routeName = initialRouteName,
params = initialRouteParams,
}))
end
local initialRouteConfig = routeConfigs[initialRouteName]
-- we need to check if the routeConfig is a table because functions can't be
-- indexed in Lua.
local initialRouteConfigParams = type(initialRouteConfig) == "table" and initialRouteConfig.params
local params = (initialRouteConfigParams or route.params or action.params or initialRouteParams)
and Cryo.Dictionary.join(
initialRouteConfigParams or {}, -- params set in routes table!
route.params or {},
action.params or {},
initialRouteParams or {} -- params provided at top level
)
local initialRouteKey = config.initialRouteKey
route = Cryo.Dictionary.join(route, {
params = params,
routeName = initialRouteName,
key = action.key or initialRouteKey or KeyGenerator.generateKey()
})
return {
key = STACK_ROUTER_ROOT_KEY,
isTransitioning = false,
index = 1,
routes = { route }
}
end
local function getParamsForRouteAndAction(routeName, action)
local routeConfig = routeConfigs[routeName]
-- we need to check if the routeConfig is a table because functions can't be
-- indexed in Lua.
if type(routeConfig) == "table" and routeConfig.params then
return Cryo.Dictionary.join(routeConfig.params, action.params)
else
return action.params
end
end
-- Strip out the CHILD_IS_SCREEN hacked elements before exposing publicly.
local strippedChildRouters = {}
for routerName, router in pairs(childRouters) do
if router ~= CHILD_IS_SCREEN then
strippedChildRouters[routerName] = router
end
end
local StackRouter = {
childRouters = strippedChildRouters,
getScreenOptions = createConfigGetter(routeConfigs, config.defaultNavigationOptions),
_CHILD_IS_SCREEN = CHILD_IS_SCREEN, -- expose symbol for testing purposes
}
function StackRouter.getComponentForState(state)
local activeChildRoute = state.routes[state.index] or {}
local routeName = activeChildRoute.routeName
validate(routeName, "There is no route defined for index '%d'. " ..
"Make sure that you passed in a navigation state with a " ..
"valid stack index.", state.index)
local childRouter = childRouters[routeName]
-- we need to check if initialChildRouter is not CHILD_IS_SCREEN because
-- of the divergence with react-navigation.
if childRouter ~= nil and childRouter ~= CHILD_IS_SCREEN then
return childRouters[routeName].getComponentForState(activeChildRoute)
end
return getScreenForRouteName(routeConfigs, routeName)
end
function StackRouter.getComponentForRouteName(routeName)
return getScreenForRouteName(routeConfigs, routeName)
end
function StackRouter.getActionCreators(route, navStateKey)
return Cryo.Dictionary.join(getCustomActionCreators(route, navStateKey), {
pop = function(n, params)
return StackActions.pop(Cryo.Dictionary.join({
n = n,
}, params or {}))
end,
popToTop = function(params)
return StackActions.popToTop(params)
end,
push = function(routeName, params, action)
return StackActions.push({
routeName = routeName,
params = params,
action = action,
})
end,
replace = function(replaceWith, params, action, newKey)
if type(replaceWith) == "string" then
return StackActions.replace({
routeName = replaceWith,
params = params,
action = action,
key = route.key,
newKey = newKey,
})
end
validate(type(replaceWith) == "table", "replaceWith must be a table or string")
validate(params == nil, "params cannot be provided to .replace() when specifying a table")
validate(action == nil, "Child action cannot be provided to .replace() when specifying a table")
validate(newKey == nil, "newKey cannot be provided to .replace() when specifying a table")
return StackActions.replace(replaceWith)
end,
reset = function(actions, index)
local resetIndex = index
if index == nil then
resetIndex = #actions
end
return StackActions.reset({
actions = actions,
index = resetIndex,
key = navStateKey,
})
end,
dismiss = function()
return NavigationActions.back({
key = navStateKey,
})
end,
})
end
function StackRouter.getStateForAction(action, state)
-- Set up initial state if needed
if not state then
return getInitialState(action)
end
local activeChildRoute = state.routes[state.index]
if not isResetToRootStack(action) and action.type ~= NavigationActions.Navigate then
-- Let the active child router handle the action
local activeChildRouter = childRouters[activeChildRoute.routeName]
if activeChildRouter ~= nil and activeChildRouter ~= CHILD_IS_SCREEN then
local route = activeChildRouter.getStateForAction(action, activeChildRoute)
if route ~= nil and route ~= activeChildRoute then
return StateUtils.replaceAt(
state,
activeChildRoute.key,
route,
-- the following tells replaceAt to NOT change the index to this
-- route for the setParam action, because people don't expect
-- param-setting actions to switch the active route
action.type == NavigationActions.SetParams
)
end
end
elseif action.type == NavigationActions.Navigate then
-- Traverse routes from the top of the stack to the bottom, so the
-- active route has the first opportunity, then the one before it, etc.
for i = #state.routes, 1, -1 do
local childRoute = state.routes[i]
local childRouter = childRouters[childRoute.routeName]
local childAction = action
if action.routeName == childRoute.routeName and action.action then
childAction = action.action
end
-- we need to check if initialChildRouter is not CHILD_IS_SCREEN because
-- of the divergence with react-navigation.
if childRouter ~= nil and childRouter ~= CHILD_IS_SCREEN then
local nextRouteState = childRouter.getStateForAction(childAction, childRoute)
if nextRouteState == nil or nextRouteState ~= childRoute then
local newState = StateUtils.replaceAndPrune(
state,
nextRouteState and nextRouteState.key or childRoute.key,
nextRouteState and nextRouteState or childRoute
)
local newTransitioning = state.isTransitioning
if state.index ~= newState.index then
newTransitioning = action.immediate ~= true
end
return Cryo.Dictionary.join(newState, {
isTransitioning = newTransitioning,
})
end
end
end
end
-- Handle push and navigation actions. This must happen after focused child router
-- has had its chance to handle the action.
-- If a router equals `nil` it means that it is not a childRouter or a screen.
if behavesLikePushAction(action) and childRouters[action.routeName] ~= nil then
local childRouter = childRouters[action.routeName]
validate(action.type ~= StackActions.Push or action.key == nil,
"StackRouter does not support key on the push action")
-- Before pushing a new route we first try to find one in the existing route stack
-- More information on this: https://github.com/react-navigation/rfcs/blob/master/text/0004-less-pushy-navigate.md
local findRoute = function(route)
return route.routeName == action.routeName
end
if action.key then
findRoute = function(route)
return route.key == action.key
end
end
local lastRouteIndex = Cryo.List.findWhere(state.routes, findRoute)
-- An instance of this route exists already and we're dealing with a navigate action.
if action.type ~= StackActions.Push and lastRouteIndex ~= nil then
-- If index is unchanged and params are not being set, leave state identity intact
if state.index == lastRouteIndex and not action.params then
return nil
end
-- Remove the now unused routes at the tail of the routes array
local routes = Cryo.List.getRange(state.routes, 1, lastRouteIndex)
-- Apply params if provided, otherwise leave route identity intact
if action.params then
local route = state.routes[lastRouteIndex]
routes[lastRouteIndex] = Cryo.Dictionary.join(route, {
params = Cryo.Dictionary.join(route.params or {}, action.params)
})
end
-- Return state with new index, changing isTransitioning only if index has changed
local newIsTransitioning = state.isTransitioning
if state.index ~= lastRouteIndex then
newIsTransitioning = action.immediate ~= true
end
return Cryo.Dictionary.join(state, {
isTransitioning = newIsTransitioning,
index = lastRouteIndex,
routes = routes,
})
end
local route
if childRouter ~= CHILD_IS_SCREEN then
-- Delegate to the child router with the given action, or init it
local childAction = action.action or NavigationActions.init({
params = getParamsForRouteAndAction(action.routeName, action)
})
route = Cryo.Dictionary.join({
-- does it make sense to wipe out the params here? or even to
-- add params at all? need more info about what this solves
params = getParamsForRouteAndAction(action.routeName, action),
}, childRouter.getStateForAction(childAction), {
routeName = action.routeName,
key = action.key or KeyGenerator.generateKey(),
})
else
-- Create the route from scratch
route = {
params = getParamsForRouteAndAction(action.routeName, action),
routeName = action.routeName,
key = action.key or KeyGenerator.generateKey(),
}
end
return Cryo.Dictionary.join(StateUtils.push(state, route), {
isTransitioning = action.immediate ~= true,
})
elseif action.type == StackActions.Push and childRouters[action.routeName] == nil then
-- Return the state identity to bubble the action up
return state
end
-- Handle navigation to other child routers that are not yet pushed
if behavesLikePushAction(action) then
local childRouterNames = Cryo.Dictionary.keys(childRouters)
for _, childRouterName in ipairs(childRouterNames) do
local childRouter = childRouters[childRouterName]
-- we need to check if initialChildRouter is not CHILD_IS_SCREEN because
-- of the divergence with react-navigation.
if childRouter ~= nil and childRouter ~= CHILD_IS_SCREEN then
-- For each child router, start with a blank state
local initChildRoute = childRouter.getStateForAction(NavigationActions.init())
-- Then check to see if the router handles our navigate action
local navigatedChildRoute = childRouter.getStateForAction(action, initChildRoute)
local routeToPush = nil
if navigatedChildRoute == nil then
-- Push the route if the router has 'handled' the action and returned null
routeToPush = initChildRoute
elseif navigatedChildRoute ~= initChildRoute then
-- Push the route if the state has changed in response to this navigation
routeToPush = navigatedChildRoute
end
if routeToPush then
local route = Cryo.Dictionary.join(routeToPush, {
routeName = childRouterName,
key = action.key or KeyGenerator.generateKey(),
})
return Cryo.Dictionary.join(StateUtils.push(state, route), {
isTransitioning = action.immediate ~= true,
})
end
end
end
end
-- Handle pop-to-top behavior. Make sure this happens after children have had a
-- chance to handle the action, so that the inner stack pops to top first.
if action.type == StackActions.PopToTop then
-- Refuse to handle pop to top if a key is given that does not correspond
-- to this router
if action.key and state.key ~= action.key then
return state
end
-- If we're already at the top, then we return the state with a new
-- identity so that the action is handled by this router.
if state.index > 1 then
return Cryo.Dictionary.join(state, {
isTransitioning = action.immediate ~= true,
index = 1,
routes = { state.routes[1] }
})
end
return state
end
if action.type == StackActions.Replace then
local routeIndex = nil -- luacheck: ignore routeIndex
-- If the key param is undefined, set the index to the last route in the stack
if action.key == nil and #state.routes > 0 then
routeIndex = #state.routes
else
routeIndex = Cryo.List.findWhere(state.routes, function(route)
return route.key == action.key
end)
end
if routeIndex then
local childRouter = childRouters[action.routeName]
local childState = {}
-- we need to check if initialChildRouter is not CHILD_IS_SCREEN because
-- of the divergence with react-navigation.
if childRouter ~= nil and childRouter ~= CHILD_IS_SCREEN then
local childAction = action.action or NavigationActions.init({
params = getParamsForRouteAndAction(action.routeName, action)
})
childState = childRouter.getStateForAction(childAction)
end
local routes = Cryo.List.replaceIndex(
state.routes,
routeIndex,
Cryo.Dictionary.join({
params = getParamsForRouteAndAction(action.routeName, action),
}, childState, {
routeName = action.routeName,
key = action.newKey or KeyGenerator.generateKey(),
})
)
return Cryo.Dictionary.join(state, { routes = routes })
end
end
if action.type == StackActions.CompleteTransition and
(action.key == nil or action.key == state.key) and
action.toChildKey == state.routes[state.index].key and
state.isTransitioning
then
return Cryo.Dictionary.join(state, {
isTransitioning = false,
})
end
if action.type == NavigationActions.SetParams then
local key = action.key
local lastRouteIndex = Cryo.List.findWhere(state.routes, function(route)
return route.key == key
end)
if lastRouteIndex then
local lastRoute = state.routes[lastRouteIndex]
local params = Cryo.Dictionary.join(lastRoute.params or {}, action.params or {})
local routes = Cryo.List.replaceIndex(
state.routes,
lastRouteIndex,
Cryo.Dictionary.join(lastRoute, { params = params })
)
return Cryo.Dictionary.join(state, {
routes = routes,
})
end
end
if action.type == StackActions.Reset then
-- Only handle reset actions that are unspecified or match this state key
if action.key ~= nil and action.key ~= state.key then
return state
end
local newStackActions = action.actions or {}
local newRoutes = Cryo.List.map(newStackActions, function(newStackAction)
local router = childRouters[newStackAction.routeName]
local childState = {}
-- we need to check if initialChildRouter is not CHILD_IS_SCREEN because
-- of the divergence with react-navigation.
if router ~= nil and router ~= CHILD_IS_SCREEN then
local childAction = newStackAction.action or NavigationActions.init({
params = getParamsForRouteAndAction(
newStackAction.routeName,
newStackAction
),
})
childState = router.getStateForAction(childAction)
end
return Cryo.Dictionary.join({
params = getParamsForRouteAndAction(newStackAction.routeName, newStackAction)
}, childState, {
routeName = newStackAction.routeName,
key = newStackAction.key or KeyGenerator.generateKey(),
})
end)
return Cryo.Dictionary.join(state, {
routes = newRoutes,
index = action.index,
})
end
if action.type == NavigationActions.Back or action.type == StackActions.Pop then
local key = action.key
local n = action.n
local immediate = action.immediate
local prune = action.prune
if action.type == StackActions.Pop and prune == false and key then
local index = Cryo.List.findWhere(state.routes, function(route)
return route.key == key
end)
if index ~= nil then
local count = math.max(index - (n or 1), 1)
local routes = Cryo.List.join(
Cryo.List.getRange(state.routes, 1, count),
Cryo.List.getRange(state.routes, index + 1, math.huge)
)
if #routes > 0 then
return Cryo.Dictionary.join(state, {
routes = routes,
index = #routes,
isTransitioning = immediate ~= true,
})
end
end
else
local backRouteIndex = state.index
if action.type == StackActions.Pop and n ~= nil then
-- determine the index to go back *from*. In this case, n=1 means to go
-- back from state.index, as if it were a normal "BACK" action
backRouteIndex = math.max(2, state.index - n + 1)
elseif key then
backRouteIndex = Cryo.List.findWhere(state.routes, function(route)
return route.key == key
end)
end
if backRouteIndex and backRouteIndex > 1 then
return Cryo.Dictionary.join(state, {
routes = Cryo.List.getRange(state.routes, 1, backRouteIndex - 1),
index = backRouteIndex - 1,
isTransitioning = immediate ~= true,
})
end
end
end
-- By this point in the router's state handling logic, we have handled the behavior
-- of the active route, and handled any stack actions. If we haven't returned by
-- now, we should allow non-active child routers to handle this action, and switch
-- to that index if the child state (route) does change..
local keyIndex = action.key and StateUtils.indexOf(state, action.key) or nil
-- Traverse routes from the top of the stack to the bottom, so the
-- active route has the first opportunity, then the one before it, etc.
for i = #state.routes, 1, -1 do
local childRoute = state.routes[i]
-- skip over the active child because we let it attempt to handle the action
-- earlier.
-- If a key is provided and in routes state then let's use that
-- knowledge to skip extra getStateForAction calls on other child
-- routers
if (childRoute.key ~= activeChildRoute.key) and
(keyIndex == 1 or childRoute.key == action.key)
then
local childRouter = childRouters[childRoute.routeName]
if childRouter ~= nil and childRouter ~= CHILD_IS_SCREEN then
local route = childRouter.getStateForAction(action, childRoute)
if route == nil then
return state
elseif route ~= childRoute then
return StateUtils.replaceAt(
state,
childRoute.key,
route,
-- People don't expect these actions to switch the active route
action.preserveFocus
)
end
end
end
end
return state
end
-- TODO: Implement StackRouter.getPathAndParamsForState after we add path expression support
-- TODO: Implement StackRouter.getActionForPathAndParams after we add path expression support
return StackRouter
end
@@ -0,0 +1,26 @@
local Cryo = require(script.Parent.Parent.Parent.Cryo)
local NavigationSymbol = require(script.Parent.Parent.NavigationSymbol)
local JUMP_TO_TOKEN = NavigationSymbol("JUMP_TO")
local SwitchActions = {
JumpTo = JUMP_TO_TOKEN,
}
-- deviation: we using this metatable to error when SwitchActions is indexed
-- with an unexpected key.
setmetatable(SwitchActions, {
__index = function(self, key)
error(("%q is not a valid member of SwitchActions"):format(tostring(key)), 2)
end,
})
-- Pop the top-most item off the route stack, if any.
function SwitchActions.jumpTo(payload)
return Cryo.Dictionary.join(
{ type = JUMP_TO_TOKEN, preserveFocus = true },
payload or {}
)
end
return SwitchActions
@@ -0,0 +1,445 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/62da341b672a83786b9c3a80c8a38f929964d7cc/packages/core/src/routers/SwitchRouter.js
local Root = script.Parent.Parent
local Packages = Root.Parent
local Cryo = require(Packages.Cryo)
local NavigationActions = require(Root.NavigationActions)
local BackBehavior = require(Root.BackBehavior)
local getScreenForRouteName = require(script.Parent.getScreenForRouteName)
local createConfigGetter = require(script.Parent.createConfigGetter)
local validateRouteConfigMap = require(script.Parent.validateRouteConfigMap)
local validateRouteConfigArray = require(script.Parent.validateRouteConfigArray)
local validate = require(Root.utils.validate)
local StackActions = require(Root.routers.StackActions)
local SwitchActions = require(script.Parent.SwitchActions)
local function defaultActionCreators()
return {}
end
local function mapToRouteName(element)
local routeName = next(element)
return routeName
end
local function foldToRoutes(routes, element)
local routeName, value = next(element)
routes[routeName] = value
return routes
end
return function(routeArray, config)
validateRouteConfigArray(routeArray)
config = config or {}
local routeConfigs = validateRouteConfigMap(
Cryo.List.foldLeft(routeArray, foldToRoutes, {})
)
-- Order is how we map the active index into the list of possible routes.
-- Lua does not guarantee any sense of order of table keys in dictionaries, so
-- we have to deviate from react-navigation SwitchRouter API. Instead of using a
-- map for the routeConfigs, we wrap each key-value pair into its own table so that
-- routeConfigs becomes an array (i.e. { { Foo = Screen }, { Bar = Screen } }).
local order = config.order or Cryo.List.map(routeArray, mapToRouteName)
local getCustomActionCreators = config.getCustomActionCreators or defaultActionCreators
local initialRouteParams = config.initialRouteParams
local initialRouteName = config.initialRouteName or order[1]
local backBehavior = config.backBehavior or BackBehavior.None
local resetOnBlur = true
if config.resetOnBlur ~= nil then
resetOnBlur = config.resetOnBlur
end
local initialRouteIndex = Cryo.List.find(order, initialRouteName)
if initialRouteIndex == nil then
local availableRouteNames = table.concat(
Cryo.List.map(order, function(routeName)
return ('"%s"'):format(routeName)
end),
", "
)
error(("Invalid initialRouteName '%s'. Should be one of %s"):format(
initialRouteName,
availableRouteNames
), 2)
end
local childRouters = {}
for _, routeName in ipairs(order) do
childRouters[routeName] = false
local screen = getScreenForRouteName(routeConfigs, routeName)
if type(screen) == "table" and screen.router then
childRouters[routeName] = screen.router
end
end
local function getParamsForRoute(routeName, params)
local routeConfig = routeConfigs[routeName]
if type(routeConfig) == "table" and routeConfig.params then
return Cryo.Dictionary.join(routeConfig.params, params or {})
else
return params
end
end
local function resetChildRoute(routeName)
local initialParams = routeName == initialRouteName and initialRouteParams or nil
-- note(brentvatne): merging initialRouteParams *on top* of default params
-- on the route seems incorrect but it's consistent with existing behavior
-- in stackrouter
local params = getParamsForRoute(routeName, initialParams)
local childRouter = childRouters[routeName]
if childRouter then
local childAction = NavigationActions.init()
return Cryo.Dictionary.join(childRouter.getStateForAction(childAction), {
key = routeName,
routeName = routeName,
params = params,
})
end
return {
key = routeName,
routeName = routeName,
params = params,
}
end
local function getNextState(action, prevState, possibleNextState)
local nextState = possibleNextState
if prevState and possibleNextState and
prevState.index ~= possibleNextState.index and
resetOnBlur
then
local prevRouteName = prevState.routes[prevState.index].routeName
local nextRoutes = Cryo.List.join(possibleNextState.routes)
nextRoutes[prevState.index] = resetChildRoute(prevRouteName)
nextState = Cryo.Dictionary.join(
possibleNextState,
{ routes = nextRoutes }
)
end
if backBehavior ~= BackBehavior.History or
(prevState and nextState and nextState.index == prevState.index )
then
return nextState
end
local nextRouteKeyHistory = prevState and prevState.routeKeyHistory or {}
if action.type == NavigationActions.Navigate then
local keyToAdd = nextState.routes[nextState.index].key
nextRouteKeyHistory = Cryo.List.filter(nextRouteKeyHistory, function(k)
return k ~= keyToAdd
end)
table.insert(nextRouteKeyHistory, keyToAdd)
elseif action.type == NavigationActions.Back then
nextRouteKeyHistory = Cryo.List.removeIndex(nextRouteKeyHistory, #nextRouteKeyHistory)
end
return Cryo.Dictionary.join(
nextState,
{ routeKeyHistory = nextRouteKeyHistory }
)
end
local function getInitialState()
local routes = Cryo.List.map(order, resetChildRoute)
local initialState = {
routes = routes,
index = initialRouteIndex,
}
if backBehavior == BackBehavior.History then
local initialKey = routes[initialRouteIndex].key
initialState.routeKeyHistory = { initialKey }
end
return initialState
end
local SwitchRouter = {
childRouters = childRouters,
getActionCreators = function(route, stateKey)
return getCustomActionCreators(route, stateKey)
end,
getScreenOptions = createConfigGetter(routeConfigs, config.defaultNavigationOptions)
}
function SwitchRouter.getStateForAction(action, inputState)
local prevState = inputState and Cryo.Dictionary.join(inputState) or nil
local state = inputState or getInitialState()
local activeChildIndex = state.index
if action.type == NavigationActions.Init then
-- TODO: React-Navigation has a comment that wonders why we merge params into child routes.
-- Need to understand if we really want to do this.
local params = action.params
if params then
state.routes = Cryo.List.map(state.routes, function(route)
local initialParams = route.routeName == initialRouteName and initialRouteParams or {}
return Cryo.Dictionary.join(route, {
params = Cryo.Dictionary.join(route.params or {}, params, initialParams)
})
end)
end
end
if action.type == SwitchActions.JumpTo and
(action.key == nil or action.key == state.key)
then
local params = action.params
local index = Cryo.List.findWhere(state.routes, function(route)
return route.routeName == action.routeName
end)
if index == nil then
error(
("There is no route named '%s' in the navigator with the key '%s'.\n"):format(
action.routeName,
action.key
) ..
"Must be one of: " .. table.concat(
Cryo.List.map(state.routes, function(route)
return route.routeName
end),
","
)
)
end
local routes = state.routes
if params then
return state.routes.map(function(route, i)
if i == index then
return Cryo.Dictionary.join(route, {
params = Cryo.Dictionary.join(route.params, params),
})
end
return route
end)
end
return getNextState(action, prevState, Cryo.Dictionary.join(state, {
routes = routes,
index = index,
}))
end
-- Let the current child handle it
local activeChildLastState = state.routes[state.index]
local activeChildRouter = childRouters[order[state.index]]
if activeChildRouter then
local activeChildState = activeChildRouter.getStateForAction(
action,
activeChildLastState
)
if not activeChildState and inputState then
return nil
end
if activeChildState and activeChildState ~= activeChildLastState then
local routes = Cryo.List.join(state.routes)
routes[state.index] = activeChildState
return getNextState(action, prevState, Cryo.Dictionary.join(
state,
{ routes = routes }
))
end
end
-- Handle tab changing. Do this after letting the current tab try to
-- handle the action, to allow inner children to change first
local isBackEligible = action.key == nil or action.key == activeChildLastState.key
if action.type == NavigationActions.Back then
if isBackEligible and backBehavior == BackBehavior.InitialRoute then
activeChildIndex = initialRouteIndex
elseif isBackEligible and backBehavior == BackBehavior.Order then
activeChildIndex = math.max(1, activeChildIndex - 1)
-- The history contains current route, so we can only go back
-- if there is more than one item in the history
elseif isBackEligible and
backBehavior == BackBehavior.History and
#state.routeKeyHistory > 1
then
local routeKey = state.routeKeyHistory[#state.routeKeyHistory - 1]
activeChildIndex = Cryo.List.find(order, routeKey)
end
end
local didNavigate = false
if action.type == NavigationActions.Navigate then
didNavigate = nil ~= Cryo.List.findWhere(order, function(childId, i)
if childId == action.routeName then
activeChildIndex = i
return true
end
return false
end)
if didNavigate then
local childState = state.routes[activeChildIndex]
local childRouter = childRouters[action.routeName]
local newChildState = childState
if action.action and childRouter then
local childStateUpdate = childRouter.getStateForAction(
action.action,
childState
)
if childStateUpdate then
newChildState = childStateUpdate
end
end
if action.params then
newChildState = Cryo.Dictionary.join(newChildState, {
params = Cryo.Dictionary.join(
newChildState.params or {},
action.params
),
})
end
if newChildState ~= childState then
local routes = Cryo.List.join(state.routes)
routes[activeChildIndex] = newChildState
local nextState = Cryo.Dictionary.join(state, {
routes = routes,
index = activeChildIndex,
})
return getNextState(action, prevState, nextState)
elseif newChildState == childState and
state.index == activeChildIndex and
prevState
then
return nil
end
end
end
if action.type == NavigationActions.SetParams then
local key = action.key
local lastRouteIndex = Cryo.List.findWhere(state.routes, function(route)
return route.key == key
end)
if lastRouteIndex ~= nil then
local lastRoute = state.routes[lastRouteIndex]
local params = Cryo.Dictionary.join(lastRoute.params or {}, action.params)
local routes = Cryo.List.join(state.routes)
routes[lastRouteIndex] = Cryo.Dictionary.join(
lastRoute,
{ params = params }
)
return getNextState(action, prevState, Cryo.Dictionary.join(
state,
{ routes = routes })
)
end
end
if activeChildIndex ~= state.index then
return getNextState(action, prevState, Cryo.Dictionary.join(
state,
{ index = activeChildIndex })
)
elseif didNavigate and not inputState then
return state
elseif didNavigate then
return Cryo.List.join(state)
end
local isActionBackOrPop = action.type == NavigationActions.Back or
action.type == StackActions.Pop or action.type == StackActions.PopToTop
local sendActionToInactiveChildren = not isActionBackOrPop or
(action.type == NavigationActions.Back and action.key ~= nil)
-- Let other children handle it and switch to the first child that returns a new state
-- Do not do this for StackActions.Pop or NavigationActions.Back actions without a key:
-- it would be unintuitive for these actions to switch to another tab just because that tab had a Stack that could accept a back action
if sendActionToInactiveChildren then
local index = state.index
local routes = state.routes
Cryo.List.findWhere(order, function(childId, i)
local childRouter = childRouters[childId]
if i == index then
return false
end
local childState = routes[i]
if childRouter then
childState = childRouter.getStateForAction(action, childState)
end
if not childState then
index = i
return true
end
if childState ~= routes[i] then
routes = Cryo.List.join(routes)
routes[i] = childState
index = i
return true
end
return false
end)
-- Nested routers can be updated after switching children with actions such as SetParams
-- and CompleteTransition.
if action.preserveFocus then
index = state.index
end
if index ~= state.index or routes ~= state.routes then
return getNextState(action, prevState, Cryo.Dictionary.join(state, {
index = index,
routes = routes,
}))
end
end
return state
end
function SwitchRouter.getComponentForState(state)
local activeRoute = state.routes[state.index] or {}
local routeName = activeRoute.routeName
validate(routeName, "There is no route defined for index '%d'. " ..
"Check that you passed in a navigation state with a " ..
"valid tab/screen index.", state.index)
local childRouter = childRouters[routeName]
if childRouter then
return childRouter.getComponentForState(state.routes[state.index])
end
return getScreenForRouteName(routeConfigs, routeName)
end
function SwitchRouter.getComponentForRouteName(routeName)
return getScreenForRouteName(routeConfigs, routeName)
end
-- TODO: Implement SwitchRouter.getPathAndParamsForState after we add path expression support
-- TODO: Implement SwitchRouter.getActionForPathAndParams after we add path expression support
return SwitchRouter
end
@@ -0,0 +1,18 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/62da341b672a83786b9c3a80c8a38f929964d7cc/packages/core/src/routers/TabRouter.js
local Cryo = require(script.Parent.Parent.Parent.Cryo)
local SwitchRouter = require(script.Parent.SwitchRouter)
local BackBehavior = require(script.Parent.Parent.BackBehavior)
return function(routeArray, config)
-- Provide defaults suitable for tab routing.
local switchConfig = {
resetOnBlur = false,
backBehavior = BackBehavior.InitialRoute,
}
if config then
switchConfig = Cryo.Dictionary.join(switchConfig, config)
end
return SwitchRouter(routeArray, switchConfig)
end
@@ -0,0 +1,580 @@
-- upstream: https://github.com/react-navigation/react-navigation/blob/a57e47786c5654a3803582b2c4953547164f26a0/packages/core/src/routers/__tests__/Routers.test.js
return function()
local Root = script.Parent.Parent.Parent
local Packages = Root.Parent
local Cryo = require(Packages.Cryo)
local Roact = require(Packages.Roact)
local StackRouter = require(script.Parent.Parent.StackRouter)
local TabRouter = require(script.Parent.Parent.TabRouter)
local SwitchRouter = require(script.Parent.Parent.SwitchRouter)
local NavigationActions = require(Root.NavigationActions)
local StackActions = require(Root.routers.StackActions)
local KeyGenerator = require(Root.utils.KeyGenerator)
local expectDeepEqual = require(Root.utils.expectDeepEqual)
beforeEach(function()
KeyGenerator._TESTING_ONLY_normalize_keys()
end)
local ROUTERS = {
TabRouter = TabRouter,
StackRouter = StackRouter,
SwitchRouter = SwitchRouter,
}
local function dummyEventSubscriber()
return { remove = function() end }
end
for routerName in pairs(ROUTERS) do
local Router = ROUTERS[routerName]
describe(("General router features - %s"):format(routerName), function()
it(("title is configurable using navigationOptions and getScreenOptions - %s"):format(routerName), function()
local FooView = Roact.Component:extend("FooView")
function FooView:render()
return Roact.createElement("Frame")
end
local BarView = Roact.Component:extend("BarView")
function BarView:render()
return Roact.createElement("Frame")
end
BarView.navigationOptions = {
title = "BarTitle",
}
local BazView = Roact.Component:extend("BazView")
function BazView:render()
return Roact.createElement("Frame")
end
BazView.navigationOptions = function(options)
local navigation = options.navigation
return { title = ("Baz-%s"):format(navigation.state.params.id) }
end
local router = Router({
{ Foo = { screen = FooView } },
{ Bar = { screen = BarView } },
{ Baz = { screen = BazView } },
})
local routes = {
{ key = "A", routeName = "Foo" },
{ key = "B", routeName = "Bar" },
{ key = "A", routeName = "Baz", params = { id = "123" } },
}
expect(
router.getScreenOptions(
{
state = routes[1],
dispatch = function() return false end,
addListener = dummyEventSubscriber,
},
{}
).title
).to.equal(nil)
expect(
router.getScreenOptions(
{
state = routes[2],
dispatch = function() return false end,
addListener = dummyEventSubscriber,
},
{}
).title
).to.equal("BarTitle")
expect(
router.getScreenOptions(
{
state = routes[3],
dispatch = function() return false end,
addListener = dummyEventSubscriber,
},
{}
).title
).to.equal("Baz-123")
end)
it(("set params works in %s"):format(routerName), function()
local FooView = Roact.Component:extend("FooView")
function FooView:render()
return Roact.createElement("Frame")
end
local router = Router({
{ Foo = { screen = FooView } },
{ Bar = { screen = FooView } },
})
local initState = router.getStateForAction(NavigationActions.init())
local initRoute = initState.routes[initState.index]
expect(initRoute.params).to.equal(nil)
local state0 = router.getStateForAction(
NavigationActions.setParams({ params = {foo = 42}, key = initRoute.key }),
initState
)
expect(state0.routes[state0.index].params.foo).to.equal(42)
end)
it("merges existing params when set params on existing state", function()
local function Screen()
return Roact.createElement("Frame")
end
local router = Router({
{ Foo = { screen = Screen, params = { a = 1 } } },
})
local key = "Foo"
local state = router.getStateForAction({
type = NavigationActions.Init,
key = key,
})
expect(state.index).to.equal(1)
expect(state.routes[1].key).to.equal(key)
expectDeepEqual(state.routes[1].params, { a = 1 })
local newState = router.getStateForAction(
NavigationActions.setParams({ key = key, params = { b = 2 } }),
state
)
expectDeepEqual(newState.routes[newState.index].params, { a = 1, b = 2 })
end)
it("merges params when setting params during init", function()
local function Screen()
return Roact.createElement("Frame")
end
local router = Router({
{ Foo = { screen = Screen, params = { a = 1 } } },
})
local newState = router.getStateForAction(
NavigationActions.setParams({ key = "Foo", params = { b = 2 } })
)
expectDeepEqual(
newState.routes[newState.index].params,
{ a = 1, b = 2 }
)
end)
end)
end
it("Nested navigate behavior test", function()
local function Leaf()
return Roact.createElement("Frame")
end
local First = Roact.Component:extend("First")
function First:render()
return Roact.createElement("Frame")
end
First.router = StackRouter({
{ First1 = Leaf },
{ First2 = Leaf },
})
local Second = Roact.Component:extend("Second")
function Second:render()
return Roact.createElement("Frame")
end
Second.router = StackRouter({
{ Second1 = Leaf },
{ Second2 = Leaf },
})
local Main = Roact.Component:extend("Main")
function Main:render()
return Roact.createElement("Frame")
end
Main.router = StackRouter({
{ First = First },
{ Second = Second },
})
local TestRouter = SwitchRouter({
{ Login = Leaf },
{ Main = Main },
})
local state1 = TestRouter.getStateForAction({ type = NavigationActions.Init })
local state2 = TestRouter.getStateForAction(
{ type = NavigationActions.Navigate, routeName = "First" },
state1
)
expect(state2.index).to.equal(2)
expect(state2.routes[2].index).to.equal(1)
expect(state2.routes[2].routes[1].index).to.equal(1)
local state3 = TestRouter.getStateForAction(
{ type = NavigationActions.Navigate, routeName = "Second2" },
state2
)
expect(state3.index).to.equal(2)
expect(state3.routes[2].index).to.equal(2) -- second
expect(state3.routes[2].routes[2].index).to.equal(2) -- second.second2
local state4 = TestRouter.getStateForAction(
{
type = NavigationActions.Navigate,
routeName = "First",
action = { type = NavigationActions.Navigate, routeName = "First2" },
},
state3,
true
)
expect(state4.index).to.equal(2) -- main
expect(state4.routes[2].index).to.equal(1) -- first
expect(state4.routes[2].routes[1].index).to.equal(2) -- first2
local state5 = TestRouter.getStateForAction(
{
type = NavigationActions.Navigate,
routeName = "First",
action = { type = NavigationActions.Navigate, routeName = "First1" },
},
state3 -- second.second2 is active on state3
)
expect(state5.index).to.equal(2) -- main
expect(state5.routes[2].index).to.equal(1) -- first
expect(state5.routes[2].routes[1].index).to.equal(1) -- first.first1
end)
it("Handles no-op actions with tabs within stack router", function()
local function BarView()
return Roact.createElement("Frame")
end
local FooTabNavigator = Roact.Component:extend("FooTabNavigator")
function FooTabNavigator:render()
return Roact.createElement("Frame")
end
FooTabNavigator.router = TabRouter({
{ Zap = { screen = BarView } },
{ Zoo = { screen = BarView } },
})
local TestRouter = StackRouter({
{ Foo = {screen = FooTabNavigator } },
{ Bar = {screen = BarView } },
})
local state1 = TestRouter.getStateForAction({ type = NavigationActions.Init })
local state2 = TestRouter.getStateForAction(
{ type = NavigationActions.Navigate, routeName = "Qux"}
)
expect(state1.routes[1].key).to.equal("id-0")
expect(state2.routes[1].key).to.equal("id-1")
state1.routes[1].key = state2.routes[1].key
expectDeepEqual(state1, state2)
local state3 = TestRouter.getStateForAction(
{ type = NavigationActions.Navigate, routeName = "Zap" },
state2
)
expect(state2, state3)
end)
it("Handles deep action", function()
local function BarView()
return Roact.createElement("Frame")
end
local FooTabNavigator = Roact.Component:extend("FooTabNavigator")
function FooTabNavigator:render()
return Roact.createElement("Frame")
end
FooTabNavigator.router = TabRouter({
{ Zap = {screen = BarView } },
{ Zoo = {screen = BarView } },
})
local TestRouter = StackRouter({
{ Bar = {screen = BarView} },
{ Foo = {screen = FooTabNavigator} },
})
local state1 = TestRouter.getStateForAction({ type = NavigationActions.Init })
local expectedState = {
index = 1,
isTransitioning = false,
key = "StackRouterRoot",
routes = {
{ key = "id-0", routeName = "Bar" },
},
}
expectDeepEqual(state1, expectedState)
local state2 = TestRouter.getStateForAction(
{
type = NavigationActions.Navigate,
routeName = "Foo",
immediate = true,
action = { type = NavigationActions.Navigate, routeName = "Zoo" },
},
state1
)
expect(state2 and state2.index).to.equal(2)
expect(state2 and state2.routes[2].index).to.equal(2)
end)
it("Handles the navigate action with params", function()
local FooTabNavigator = Roact.Component:extend("FooTabNavigator")
function FooTabNavigator:render()
return Roact.createElement("Frame")
end
FooTabNavigator.router = TabRouter({
{ Baz = { screen = function() return Roact.createElement("Frame") end } },
{ Boo = { screen = function() return Roact.createElement("Frame") end } },
})
local TestRouter = StackRouter({
{ Foo = { screen = function() return Roact.createElement("Frame") end } },
{ Bar = { screen = FooTabNavigator } },
})
local state = TestRouter.getStateForAction({ type = NavigationActions.Init })
local state2 = TestRouter.getStateForAction(
{
type = NavigationActions.Navigate,
immediate = true,
routeName = "Bar",
params = { foo = "42" },
},
state
)
expectDeepEqual(state2 and state2.routes[2].params, { foo = "42" })
expectDeepEqual(state2 and state2.routes[2].routes, {
{
key = "Baz",
routeName = "Baz",
params = { foo = "42" },
},
{
key = "Boo",
routeName = "Boo",
params = { foo = "42" },
},
})
end)
it("Handles the setParams action", function()
local FooTabNavigator = Roact.Component:extend("FooTabNavigator")
function FooTabNavigator:render()
return Roact.createElement("Frame")
end
FooTabNavigator.router = TabRouter({
{ Baz = { screen = function() return Roact.createElement("Frame") end } },
})
local TestRouter = StackRouter({
{ Foo = { screen = FooTabNavigator } },
{ Bar = { screen = function() return Roact.createElement("Frame") end } },
})
local state = TestRouter.getStateForAction({ type = NavigationActions.Init })
local state2 = TestRouter.getStateForAction(
{
type = NavigationActions.SetParams,
params = { name = "foobar" },
key = "Baz",
},
state
)
expect(state2 and state2.index).to.equal(1)
expectDeepEqual(state2 and state2.routes[1].routes, {
{
key = "Baz",
routeName = "Baz",
params = { name = "foobar" },
},
})
end)
it("Supports lazily-evaluated getScreen", function()
local function BarView()
return Roact.createElement("Frame")
end
local FooTabNavigator = Roact.Component:extend("FooTabNavigator")
function FooTabNavigator:render()
return Roact.createElement("Frame")
end
FooTabNavigator.router = TabRouter({
{ Zap = { screen = BarView } },
{ Zoo = { screen = BarView } },
})
local TestRouter = StackRouter({
{ Foo = { screen = FooTabNavigator } },
{ Bar = { getScreen = function() return BarView end } },
})
local state1 = TestRouter.getStateForAction({ type = NavigationActions.Init })
local state2 = TestRouter.getStateForAction(
{ type = NavigationActions.Navigate, immediate = true, routeName = "Qux" }
)
expect(state1.routes[1].key).to.equal("id-0")
expect(state2.routes[1].key).to.equal("id-1")
state1.routes[1].key = state2.routes[1].key
expectDeepEqual(state1, state2)
local state3 = TestRouter.getStateForAction(
{ type = NavigationActions.Navigate, immediate = true, routeName = "Zap" },
state2
)
expectDeepEqual(state2, state3)
end)
it("Does not switch tab index when TabRouter child handles COMPLETE_NAVIGATION or SET_PARAMS", function()
local FooStackNavigator = Roact.Component:extend("FooStackNavigator")
function FooStackNavigator:render()
return Roact.createElement("Frame")
end
local function BarView()
return Roact.createElement("Frame")
end
FooStackNavigator.router = StackRouter({
{ Foo = { screen = BarView } },
{ Bar = { screen = BarView } },
})
local TestRouter = TabRouter({
{ Zap = { screen = FooStackNavigator } },
{ Zoo = { screen = FooStackNavigator } },
})
local state1 = TestRouter.getStateForAction({ type = NavigationActions.Init })
-- Navigate to the second screen in the first tab
local state2 = TestRouter.getStateForAction(
{ type = NavigationActions.Navigate, routeName = "Bar" },
state1
)
-- Switch tabs
local state3 = TestRouter.getStateForAction(
{ type = NavigationActions.Navigate, routeName = "Zoo" },
state2
)
local stateAfterCompleteTransition = TestRouter.getStateForAction(
{
type = StackActions.CompleteTransition,
preserveFocus = true,
key = state2.routes[1].key,
},
state3
)
local stateAfterSetParams = TestRouter.getStateForAction(
{
type = NavigationActions.SetParams,
preserveFocus = true,
key = state1.routes[1].routes[1].key,
params = { key = "value" },
},
state3
)
expect(stateAfterCompleteTransition.index).to.equal(2)
expect(stateAfterSetParams.index).to.equal(2)
end)
it("Inner actions are only unpacked if the current tab matches", function()
local function PlainScreen()
return Roact.createElement("Frame")
end
local ScreenA = Roact.Component:extend("ScreenA")
function ScreenA:render()
return Roact.createElement("Frame")
end
local ScreenB = Roact.Component:extend("ScreenB")
function ScreenB:render()
return Roact.createElement("Frame")
end
ScreenB.router = StackRouter({
{ Baz = { screen = PlainScreen } },
{ Zoo = { screen = PlainScreen } },
})
ScreenA.router = StackRouter({
{ Bar = { screen = PlainScreen } },
{ Boo = { screen = ScreenB } },
})
local TestRouter = TabRouter({
{ Foo = { screen = ScreenA } },
})
local screenApreState = {
index = 1,
key = "Init",
isTransitioning = false,
routeName = "Foo",
routes = {
{ key = "Init", routeName = "Bar" },
},
}
local preState = {
index = 1,
isTransitioning = false,
routes = { screenApreState },
}
local function comparable(state)
local result = {}
if typeof(state.routeName) == "string" then
result = Cryo.Dictionary.join(result, {
routeName = state.routeName
})
end
if typeof(state.routes) == "table" then
result = Cryo.Dictionary.join(result, {
routes = Cryo.List.map(state.routes, comparable)
})
end
return result
end
local action = NavigationActions.navigate({
routeName = "Boo",
action = NavigationActions.navigate({ routeName = "Zoo" }),
})
local expectedState = ScreenA.router.getStateForAction(action, screenApreState)
local state = TestRouter.getStateForAction(action, preState)
local innerState = state and state.routes[1] or state
expectDeepEqual(
expectedState and comparable(expectedState),
innerState and comparable(innerState)
)
end)
end
@@ -0,0 +1,99 @@
return function()
local StackActions = require(script.Parent.Parent.StackActions)
it("throws when indexing an unknown field", function()
expect(function()
return StackActions.foo
end).to.throw("\"foo\" is not a valid member of StackActions")
end)
describe("StackActions token tests", function()
it("should return same object for each token for multiple calls", function()
expect(StackActions.Pop).to.equal(StackActions.Pop)
expect(StackActions.PopToTop).to.equal(StackActions.PopToTop)
expect(StackActions.Push).to.equal(StackActions.Push)
expect(StackActions.Reset).to.equal(StackActions.Reset)
expect(StackActions.Replace).to.equal(StackActions.Replace)
end)
it("should return matching string names for symbols", function()
expect(tostring(StackActions.Pop)).to.equal("POP")
expect(tostring(StackActions.PopToTop)).to.equal("POP_TO_TOP")
expect(tostring(StackActions.Push)).to.equal("PUSH")
expect(tostring(StackActions.Reset)).to.equal("RESET")
expect(tostring(StackActions.Replace)).to.equal("REPLACE")
end)
end)
describe("StackActions function tests", function()
it("should return a pop action for pop()", function()
local popTable = StackActions.pop({
n = "n",
})
expect(popTable.type).to.equal(StackActions.Pop)
expect(popTable.n).to.equal("n")
end)
it("should return a pop to top action for popToTop()", function()
local popToTopTable = StackActions.popToTop()
expect(popToTopTable.type).to.equal(StackActions.PopToTop)
end)
it("should return a push action for push()", function()
local pushTable = StackActions.push({
routeName = "routeName",
params = "params",
action = "action",
})
expect(pushTable.type).to.equal(StackActions.Push)
expect(pushTable.routeName).to.equal("routeName")
expect(pushTable.params).to.equal("params")
expect(pushTable.action).to.equal("action")
end)
it("should return a reset action for reset()", function()
local resetTable = StackActions.reset({
index = "index",
actions = "actions",
key = "key",
})
expect(resetTable.type).to.equal(StackActions.Reset)
expect(resetTable.index).to.equal("index")
expect(resetTable.key).to.equal("key")
end)
it("should return a replace action for replace()", function()
local replaceTable = StackActions.replace({
key = "key",
newKey = "newKey",
routeName = "routeName",
params = "params",
action = "action",
immediate = "immediate",
})
expect(replaceTable.type).to.equal(StackActions.Replace)
expect(replaceTable.key).to.equal("key")
expect(replaceTable.newKey).to.equal("newKey")
expect(replaceTable.routeName).to.equal("routeName")
expect(replaceTable.params).to.equal("params")
expect(replaceTable.action).to.equal("action")
expect(replaceTable.immediate).to.equal("immediate")
end)
it("should return a complete transition action with matching data for call to completeTransition()", function()
local completeTransitionTable = StackActions.completeTransition({
key = "key",
toChildKey = "toChildKey",
})
expect(completeTransitionTable.type).to.equal(StackActions.CompleteTransition)
expect(completeTransitionTable.key).to.equal("key")
expect(completeTransitionTable.toChildKey).to.equal("toChildKey")
end)
end)
end
@@ -0,0 +1,31 @@
return function()
local SwitchActions = require(script.Parent.Parent.SwitchActions)
it("throws when indexing an unknown field", function()
expect(function()
return SwitchActions.foo
end).to.throw("\"foo\" is not a valid member of SwitchActions")
end)
describe("token tests", function()
it("returns same object for each token for multiple calls", function()
expect(SwitchActions.JumpTo).to.equal(SwitchActions.JumpTo)
end)
it("should return matching string names for symbols", function()
expect(tostring(SwitchActions.JumpTo)).to.equal("JUMP_TO")
end)
end)
describe("creators", function()
it("returns a JumpTo action for jumpTo()", function()
local popTable = SwitchActions.jumpTo({
routeName = "foo",
})
expect(popTable.type).to.equal(SwitchActions.JumpTo)
expect(popTable.routeName).to.equal("foo")
expect(popTable.preserveFocus).to.equal(true)
end)
end)
end
@@ -0,0 +1,589 @@
return function()
local Root = script.Parent.Parent.Parent
local SwitchRouter = require(script.Parent.Parent.SwitchRouter)
local NavigationActions = require(Root.NavigationActions)
local BackBehavior = require(Root.BackBehavior)
it("should be a function", function()
expect(type(SwitchRouter)).to.equal("function")
end)
it("should throw when passed a non-table", function()
expect(function()
SwitchRouter(5)
end).to.throw("routeConfigs must be an array table")
end)
it("should throw if initialRouteName is not found in routes table", function()
expect(function()
SwitchRouter({
{ Foo = function() end },
{ Bar = function() end },
}, {
initialRouteName = "MyRoute",
})
end).to.throw("Invalid initialRouteName 'MyRoute'. Should be one of \"Foo\", \"Bar\"")
end)
it("should expose childRouters as a member", function()
local router = SwitchRouter({
{
Foo = {
screen = {
render = function() end,
router = "A",
},
},
},
{
Bar = {
screen = {
render = function() end,
router = "B",
},
},
},
})
expect(router.childRouters.Foo).to.equal("A")
expect(router.childRouters.Bar).to.equal("B")
end)
describe("getScreenOptions tests", function()
it("should correctly configure default screen options", function()
local router = SwitchRouter({
{
Foo = {
screen = {
render = function() end,
}
}
},
}, {
defaultNavigationOptions = {
title = "FooTitle",
},
})
local screenOptions = router.getScreenOptions({
state = {
routeName = "Foo",
}
})
expect(screenOptions.title).to.equal("FooTitle")
end)
it("should correctly configure route-specified screen options", function()
local router = SwitchRouter({
{
Foo = {
screen = {
render = function() end,
},
navigationOptions = { title = "RouteFooTitle" },
}
},
}, {
defaultNavigationOptions = {
title = "FooTitle",
},
})
local screenOptions = router.getScreenOptions({
state = {
routeName = "Foo",
}
})
expect(screenOptions.title).to.equal("RouteFooTitle")
end)
it("should correctly configure component-specified screen options", function()
local router = SwitchRouter({
{
Foo = {
screen = {
render = function() end,
navigationOptions = { title = "ComponentFooTitle" },
},
}
},
}, {
defaultNavigationOptions = {
title = "FooTitle",
},
})
local screenOptions = router.getScreenOptions({
state = {
routeName = "Foo",
}
})
expect(screenOptions.title).to.equal("ComponentFooTitle")
end)
end)
describe("getActionCreators tests", function()
it("should return empty action creators table if none are provided", function()
local router = SwitchRouter({
{ Foo = { render = function() end } },
})
local actionCreators = router.getActionCreators({ routeName = "Foo" }, "key")
local fieldCount = 0
for _ in pairs(actionCreators) do
fieldCount = fieldCount + 1
end
expect(fieldCount).to.equal(0)
end)
it("should call custom action creators function if provided", function()
local router = SwitchRouter({
{ Foo = { render = function() end } },
}, {
getCustomActionCreators = function()
return { a = 1 }
end,
})
local actionCreators = router.getActionCreators({ routeName = "Foo" }, "key")
expect(actionCreators.a).to.equal(1)
end)
end)
describe("getComponentForState tests", function()
it("should return component matching requested state", function()
local testComponent = function() end
local router = SwitchRouter({
{ Foo = { screen = testComponent } },
})
local component = router.getComponentForState({
routes = {
{ routeName = "Foo" },
},
index = 1,
})
expect(component).to.equal(testComponent)
end)
it("should throw if there is no route matching active index", function()
local router = SwitchRouter({
{ Foo = { screen = function() end } },
})
local message = "There is no route defined for index '2'. " ..
"Check that you passed in a navigation state with a " ..
"valid tab/screen index."
expect(function()
router.getComponentForState({
routes = {
Foo = { screen = function() end },
},
index = 2,
})
end).to.throw(message)
end)
it("should descend child router for requested route", function()
local testComponent = function() end
local childRouter = SwitchRouter({
{ Bar = { screen = testComponent } },
})
local router = SwitchRouter({
{
Foo = {
screen = {
render = function() end,
router = childRouter,
}
},
},
})
local component = router.getComponentForState({
routes = {
{
routeName = "Foo",
routes = { -- Child router's routes
{ routeName = "Bar" },
},
index = 1
},
},
index = 1,
})
expect(component).to.equal(testComponent)
end)
end)
describe("getComponentForRouteName tests", function()
it("should return a component that matches the given route name", function()
local testComponent = function() end
local router = SwitchRouter({
{ Foo = { screen = testComponent } },
})
local component = router.getComponentForRouteName("Foo")
expect(component).to.equal(testComponent)
end)
end)
describe("getStateForAction tests", function()
it("should return initial state for init action", function()
local router = SwitchRouter({
{ Foo = { screen = function() end } },
{ Bar = { screen = function() end } },
})
local state = router.getStateForAction(NavigationActions.init(), nil)
expect(#state.routes).to.equal(2)
expect(state.routes[state.index].routeName).to.equal("Foo")
end)
it("should adjust initial state index to match initialRouteName's index", function()
local router = SwitchRouter({
{ Foo = { screen = function() end } },
{ Bar = { screen = function() end } },
})
local state = router.getStateForAction(NavigationActions.init(), nil)
expect(state.routes[state.index].routeName).to.equal("Foo")
local router2 = SwitchRouter({
{ Foo = { screen = function() end } },
{ Bar = { screen = function() end } },
}, {
initialRouteName = "Bar",
})
local state2 = router2.getStateForAction(NavigationActions.init(), nil)
expect(state2.routes[state2.index].routeName).to.equal("Bar")
end)
it("should respect optional order property", function()
local router = SwitchRouter({
{ Foo = { screen = function() end } },
{ Bar = { screen = function() end } },
})
local state = router.getStateForAction(NavigationActions.init(), nil)
expect(state.routes[1].routeName).to.equal("Foo")
expect(state.routes[2].routeName).to.equal("Bar")
end)
it("should incorporate child router state", function()
local childRouter = SwitchRouter({
{ Bar = { screen = function() end } },
})
local router = SwitchRouter({
{
Foo = {
render = function() end,
router = childRouter,
},
},
{ City = { screen = function() end } },
})
local state = router.getStateForAction(NavigationActions.init(), nil)
local activeState = state.routes[state.index]
expect(activeState.routeName).to.equal("Foo") -- parent's tracking uses parent's route name
expect(activeState.routes[activeState.index].routeName).to.equal("Bar")
end)
it("should let active child handle non-init action first", function()
local childRouter = SwitchRouter({
{ Bar = { screen = function() end } },
{ City = { screen = function() end } },
})
local router = SwitchRouter({
{
Foo = {
render = function() end,
router = childRouter,
},
},
{ State = { render = function() end } },
})
local state = router.getStateForAction(NavigationActions.navigate({ routeName = "City" }))
expect(state.routes[1].index).to.equal(2)
expect(state.index).to.equal(1)
end)
it("should go back to initial route index if BackBehavior.InitialRoute", function()
local router = SwitchRouter({
{ Foo = { render = function() end } },
{ Bar = { render = function() end } },
}, {
backBehavior = BackBehavior.InitialRoute,
})
local prevState = {
routes = {
{ routeName = "Foo" },
{ routeName = "Bar" },
},
index = 2,
}
local newState = router.getStateForAction(NavigationActions.back(), prevState)
expect(newState.index).to.equal(1)
end)
it("should not change state on back action if BackBehavior.None", function()
local router = SwitchRouter({
{ Foo = { render = function() end } },
{ Bar = { render = function() end } },
})
local prevState = {
routes = {
{ routeName = "Foo" },
{ routeName = "Bar" },
},
index = 2,
}
local newState = router.getStateForAction(NavigationActions.back(), prevState)
expect(newState).to.equal(prevState)
end)
it("should change active route on navigate", function()
local router = SwitchRouter({
{ Foo = { render = function() end } },
{ Bar = { render = function() end } },
})
local newState = router.getStateForAction(NavigationActions.navigate({ routeName = "Bar" }))
expect(newState.index).to.equal(2)
expect(newState.routes[newState.index].routeName).to.equal("Bar")
end)
it("should pass sub-action to child router on navigate", function()
local childRouter = SwitchRouter({
{ City = { screen = function() end } },
{ State = { screen = function() end } },
})
local router = SwitchRouter({
{ Foo = { render = function() end } },
{ Bar = {
render = function() end,
router = childRouter,
},
},
})
local newState = router.getStateForAction(NavigationActions.navigate({
routeName = "Bar",
action = NavigationActions.navigate({ routeName = "State" }),
}))
local activeRoute = newState.routes[newState.index]
expect(activeRoute.routeName).to.equal("Bar")
expect(activeRoute.routes[activeRoute.index].routeName).to.equal("State")
end)
it("should return initial state if navigating to active child without previous state", function()
local childRouter = SwitchRouter({
{ Bar = { screen = function() end } },
})
local router = SwitchRouter({
{
Foo = {
render = function() end,
router = childRouter,
},
},
{ City = { render = function() end } },
})
local newState = router.getStateForAction(NavigationActions.navigate({
routeName = "Foo",
}))
expect(newState.routes[newState.index].routeName).to.equal("Foo")
end)
it("should reset state for deactivated route by default", function()
local router = SwitchRouter({
{ Foo = { render = function() end } },
{ Bar = { render = function() end } },
})
local initialState = {
routes = {
{ routeName = "Foo", params = { a = 1 } },
{ routeName = "Bar" },
},
index = 1,
}
local state = router.getStateForAction(NavigationActions.navigate({ routeName = "Bar" }), initialState)
expect(state.routes[1].params).to.equal(nil) -- should be empty
end)
it("should not reset state for deactivated route if resetOnBlur is false", function()
local router = SwitchRouter({
{ Foo = { render = function() end } },
{ Bar = { render = function() end } },
}, {
resetOnBlur = false,
})
local testParams = { a = 1 }
local initialState = {
routes = {
{ routeName = "Foo", params = testParams },
{ routeName = "Bar" },
},
index = 1,
}
local state = router.getStateForAction(NavigationActions.navigate({ routeName = "Bar" }), initialState)
expect(state.routes[1].params).to.equal(testParams)
end)
it("should set params on route for setParams action", function()
local router = SwitchRouter({
{ Foo = { render = function() end } },
{ Bar = { render = function() end } },
})
local newState = router.getStateForAction(NavigationActions.setParams({
key = "Foo", -- By default, key == routeName
params = { a = 1 },
}))
expect(newState.routes[newState.index].params.a).to.equal(1)
end)
it("should preserve route configured params for child router", function()
local childRouter = SwitchRouter({
{
Bar = {
screen = function() end,
params = { a = 2 },
},
},
})
local router = SwitchRouter({
{
Foo = {
render = function() end,
params = { a = 1 },
router = childRouter,
},
},
{ City = { render = function() end } },
})
local state = router.getStateForAction(NavigationActions.init())
expect(state.routes[state.index].params.a).to.equal(1)
end)
it("should merge initialRouteParams with initial route's own params", function()
local router = SwitchRouter({
{
Foo = {
render = function() end,
params = { a = 1 },
},
},
{
Bar = {
render = function() end,
params = { a = 1 },
},
},
}, {
initialRouteParams = { a = 2, b = 3 },
})
local state = router.getStateForAction(NavigationActions.init())
expect(state.routes[1].params.a).to.equal(2)
expect(state.routes[1].params.b).to.equal(3)
expect(state.routes[2].params.a).to.equal(1)
expect(state.routes[2].params.b).to.equal(nil)
end)
it("should merge init action params with initial route's own params and initialRouteParams", function()
local router = SwitchRouter({
{
Foo = { render = function() end, params = { a = 1 } }
},
}, {
initialRouteParams = { c = 3 },
})
local state = router.getStateForAction(NavigationActions.init({ params = { b = 2 } }))
expect(state.routes[1].params.a).to.equal(1)
expect(state.routes[1].params.b).to.equal(2)
expect(state.routes[1].params.c).to.equal(3)
end)
it("should merge navigate action params for child router", function()
local childRouter = SwitchRouter({
{
Bar = {
screen = function() end,
params = { a = 2 },
},
},
})
local router = SwitchRouter({
{
Foo = {
render = function() end,
router = childRouter,
},
},
})
local state = router.getStateForAction(NavigationActions.navigate({
routeName = "Bar",
params = { b = 3 },
}))
expect(state.routes[1].routes[1].params.a).to.equal(2)
expect(state.routes[1].routes[1].params.b).to.equal(3)
end)
it("should propagate a child router getStateForAction failure to caller", function()
local childRouter = SwitchRouter({
{ Bar = { screen = function() end } },
})
local router = SwitchRouter({
{
Foo = {
render = function() end,
router = childRouter,
},
},
})
-- need to properly initialize state because we're being abusive of getStateForAction
local initialState = router.getStateForAction(NavigationActions.init())
childRouter.getStateForAction = function() return nil end
local state = router.getStateForAction(NavigationActions.navigate("Bar"), initialState)
expect(state).to.equal(nil)
end)
end)
end
@@ -0,0 +1,463 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/fcd7d83c4c33ad1fa508c8cfe687d2fa259bfc2c/packages/core/src/routers/__tests__/SwitchRouter.test.js
return function()
local Root = script.Parent.Parent.Parent
local Packages = Root.Parent
local Roact = require(Packages.Roact)
local Cryo = require(Packages.Cryo)
local StackRouter = require(script.Parent.Parent.StackRouter)
local SwitchRouter = require(script.Parent.Parent.SwitchRouter)
local NavigationActions = require(Root.NavigationActions)
local BackBehavior = require(Root.BackBehavior)
local expectDeepEqual = require(Root.utils.expectDeepEqual)
local getRouterTestHelper = require(script.Parent.routerTestHelper)
local function getExampleRouter(config)
config = config or {}
local function PlainScreen()
return Roact.createElement("Frame")
end
local StackA = Roact.Component:extend("StackA")
function StackA:render()
return Roact.createElement("Frame")
end
local StackB = Roact.Component:extend("StackB")
function StackB:render()
return Roact.createElement("Frame")
end
local StackC = Roact.Component:extend("StackC")
function StackC:render()
return Roact.createElement("Frame")
end
StackA.router = StackRouter({
{A1 = PlainScreen},
{A2 = PlainScreen}
})
StackB.router = StackRouter({
{B1 = PlainScreen},
{B2 = PlainScreen}
})
StackC.router = StackRouter({
{C1 = PlainScreen},
{C2 = PlainScreen}
})
local router = SwitchRouter({
{A = {screen = StackA, path = ""}},
{B = {screen = StackB, path = "great/path"}},
{C = {screen = StackC, path = "pathC"}}
}, Cryo.Dictionary.join(
{initialRouteName = "A"},
config
))
return router
end
describe("SwitchRouter", function()
it("resets the route when unfocusing a tab by default", function()
local helper = getRouterTestHelper(getExampleRouter())
local navigateTo = helper.navigateTo
local getState = helper.getState
navigateTo("A2")
expect(getState().routes[1].index).to.equal(2)
expect(#getState().routes[1].routes).to.equal(2)
navigateTo("B")
expect(getState().routes[1].index).to.equal(1)
expect(#getState().routes[1].routes).to.equal(1)
end)
it("does not reset the route on unfocus if resetOnBlur is false", function()
local helper = getRouterTestHelper(getExampleRouter({resetOnBlur = false}))
local navigateTo = helper.navigateTo
local getState = helper.getState
navigateTo("A2")
expect(getState().routes[1].index).to.equal(2)
expect(#getState().routes[1].routes).to.equal(2)
navigateTo("B")
expect(getState().routes[1].index).to.equal(2)
expect(#getState().routes[1].routes).to.equal(2)
end)
it("ignores back by default", function()
local helper = getRouterTestHelper(getExampleRouter())
local jumpTo = helper.jumpTo
local back = helper.back
local getState = helper.getState
jumpTo("B")
expect(getState().index).to.equal(2)
back()
expect(getState().index).to.equal(2)
end)
it("handles initialRoute backBehavior", function()
local helper = getRouterTestHelper(getExampleRouter({
backBehavior = BackBehavior.InitialRoute,
initialRouteName = "B",
}))
local jumpTo = helper.jumpTo
local back = helper.back
local getState = helper.getState
expect(getState().routeKeyHistory).to.equal(nil)
expect(getState().index).to.equal(2)
jumpTo("C")
expect(getState().index).to.equal(3)
jumpTo("A")
expect(getState().index).to.equal(1)
back()
expect(getState().index).to.equal(2)
back()
expect(getState().index).to.equal(2)
end)
it("handles order backBehavior", function()
local helper = getRouterTestHelper(getExampleRouter({
backBehavior = BackBehavior.Order,
}))
local navigateTo = helper.navigateTo
local back = helper.back
local getState = helper.getState
expect(getState().routeKeyHistory).to.equal(nil)
navigateTo("C")
expect(getState().index).to.equal(3)
back()
expect(getState().index).to.equal(2)
back()
expect(getState().index).to.equal(1)
back()
expect(getState().index).to.equal(1)
end)
it("handles history backBehavior", function()
local helper = getRouterTestHelper(getExampleRouter({
backBehavior = BackBehavior.History,
}))
local navigateTo = helper.navigateTo
local back = helper.back
local getState = helper.getState
expectDeepEqual(getState().routeKeyHistory, {"A"})
navigateTo("B")
expect(getState().index).to.equal(2)
expectDeepEqual(getState().routeKeyHistory, {"A", "B"})
navigateTo("A")
expect(getState().index).to.equal(1)
expectDeepEqual(getState().routeKeyHistory, {"B", "A"})
navigateTo("C")
expect(getState().index).to.equal(3)
expectDeepEqual(getState().routeKeyHistory, {"B", "A", "C"})
navigateTo("A")
expect(getState().index).to.equal(1)
expectDeepEqual(getState().routeKeyHistory, {"B", "C", "A"})
back()
expect(getState().index).to.equal(3)
expectDeepEqual(getState().routeKeyHistory, {"B", "C"})
back()
expect(getState().index).to.equal(2)
expectDeepEqual(getState().routeKeyHistory, {"B"})
back()
expect(getState().index).to.equal(2)
expectDeepEqual(getState().routeKeyHistory, {"B"})
end)
it("handles history backBehavior without popping routeKeyHistory when child handles action", function()
local helper = getRouterTestHelper(getExampleRouter({
backBehavior = BackBehavior.History,
}))
local navigateTo = helper.navigateTo
local back = helper.back
local getState = helper.getState
local getSubState = helper.getSubState
expectDeepEqual(getState().routeKeyHistory, {
"A",
})
navigateTo("B")
expect(getState().index).to.equal(2)
expectDeepEqual(getState().routeKeyHistory, {
"A",
"B",
})
navigateTo("B2")
expect(getState().index).to.equal(2)
expectDeepEqual(getState().routeKeyHistory, {
"A",
"B",
})
expect(getSubState(2).routeName).to.equal("B2")
back()
expect(getState().index).to.equal(2)
-- "B" should not be popped when the child handles the back action
expectDeepEqual(getState().routeKeyHistory, {
"A",
"B",
})
expect(getSubState(2).routeName).to.equal("B1")
end)
it("handles back and does not apply back action to inactive child", function()
local helper = getRouterTestHelper(getExampleRouter({
backBehavior = "initialRoute",
resetOnBlur = false, -- Don't erase the state of substack B when we switch back to A
}))
local navigateTo = helper.navigateTo
local back = helper.back
local getSubState = helper.getSubState
expect(getSubState(1).routeName).to.equal("A")
navigateTo("B")
navigateTo("B2")
expect(getSubState(1).routeName).to.equal("B")
expect(getSubState(2).routeName).to.equal("B2")
navigateTo("A")
expect(getSubState(1).routeName).to.equal("A")
-- The back action should not switch to B. It should stay on A
back(nil)
expect(getSubState(1).routeName).to.equal("A")
end)
it("handles pop and does not apply pop action to inactive child", function()
local helper = getRouterTestHelper(getExampleRouter({
backBehavior = "initialRoute",
resetOnBlur = false, -- Don't erase the state of substack B when we switch back to A
}))
local navigateTo = helper.navigateTo
local pop = helper.pop
local getSubState = helper.getSubState
expect(getSubState(1).routeName).to.equal("A")
navigateTo("B")
navigateTo("B2")
expect(getSubState(1).routeName).to.equal("B")
expect(getSubState(2).routeName).to.equal("B2")
navigateTo("A")
expect(getSubState(1).routeName).to.equal("A")
-- The pop action should not switch to B. It should stay on A
pop()
expect(getSubState(1).routeName).to.equal("A")
end)
it("handles popToTop and does not apply popToTop action to inactive child", function()
local helper = getRouterTestHelper(getExampleRouter({
backBehavior = "initialRoute",
resetOnBlur = false, -- Don't erase the state of substack B when we switch back to A
}))
local navigateTo = helper.navigateTo
local popToTop = helper.popToTop
local getSubState = helper.getSubState
expect(getSubState(1).routeName).to.equal("A")
navigateTo("B")
navigateTo("B2")
expect(getSubState(1).routeName).to.equal("B")
expect(getSubState(2).routeName).to.equal("B2")
navigateTo("A")
expect(getSubState(1).routeName).to.equal("A")
-- The popToTop action should not switch to B. It should stay on A
popToTop()
expect(getSubState(1).routeName).to.equal("A")
end)
it("handles back and does switch to inactive child with matching key", function()
local helper = getRouterTestHelper(getExampleRouter({
backBehavior = "initialRoute",
resetOnBlur = false, -- Don't erase the state of substack B when we switch back to A
}))
local navigateTo = helper.navigateTo
local back = helper.back
local getSubState = helper.getSubState
expect(getSubState(1).routeName).to.equal("A")
navigateTo("B")
navigateTo("B2")
expect(getSubState(1).routeName).to.equal("B")
expect(getSubState(2).routeName).to.equal("B2")
local b2Key = getSubState(2).key
navigateTo("A")
expect(getSubState(1).routeName).to.equal("A")
-- The back action should switch to B and go back from B2 to B1
back(b2Key)
expect(getSubState(1).routeName).to.equal("B")
expect(getSubState(2).routeName).to.equal("B1")
end)
it("handles nested actions", function()
local helper = getRouterTestHelper(getExampleRouter())
local navigateTo = helper.navigateTo
local getSubState = helper.getSubState
navigateTo("B", {
action = {
type = NavigationActions.Navigate,
routeName = "B2",
},
})
expect(getSubState(1).routeName).to.equal("B")
expect(getSubState(2).routeName).to.equal("B2")
end)
it("handles nested actions and params simultaneously", function()
local helper = getRouterTestHelper(getExampleRouter())
local navigateTo = helper.navigateTo
local getSubState = helper.getSubState
local params1 = { foo = "bar" }
local params2 = { bar = "baz" }
navigateTo("B", {
params = params1,
action = {
type = NavigationActions.Navigate,
routeName = "B2",
params = params2,
},
})
expect(getSubState(1).routeName).to.equal("B")
expectDeepEqual(getSubState(1).params, params1)
expect(getSubState(2).routeName).to.equal("B2")
expectDeepEqual(getSubState(2).params, params2)
end)
it("order of handling navigate action is correct for nested switchrouters", function()
-- router = switch({ Nested: switch({ Foo, Bar }), Other: switch({ Foo }), Bar })
-- if we are focused on Other and navigate to Bar, what should happen?
local function Screen()
return Roact.createElement("Frame")
end
local NestedSwitch = Roact.Component:extend("NestedSwitch")
function NestedSwitch:render()
return Roact.createElement("Frame")
end
local OtherNestedSwitch = Roact.Component:extend("OtherNestedSwitch")
function OtherNestedSwitch:render()
return Roact.createElement("Frame")
end
local nestedRouter = SwitchRouter({
{Foo = Screen},
{Bar = Screen},
})
local otherNestedRouter = SwitchRouter({
{Foo = Screen},
})
NestedSwitch.router = nestedRouter
OtherNestedSwitch.router = otherNestedRouter
local router = SwitchRouter({
{NestedSwitch = NestedSwitch},
{OtherNestedSwitch = OtherNestedSwitch},
{Bar = Screen}
}, { initialRouteName = "OtherNestedSwitch" })
local helper = getRouterTestHelper(router)
local navigateTo = helper.navigateTo
local getSubState = helper.getSubState
expect(getSubState(1).routeName).to.equal("OtherNestedSwitch")
navigateTo("Bar")
expect(getSubState(1).routeName).to.equal("Bar")
navigateTo("NestedSwitch")
navigateTo("Bar")
expect(getSubState(1).routeName).to.equal("NestedSwitch")
expect(getSubState(2).routeName).to.equal("Bar")
end)
-- https://github.com/react-navigation/react-navigation.github.io/issues/117#issuecomment-385597628
it("order of handling navigate action is correct for nested stackrouters", function()
local function Screen()
return Roact.createElement("Frame")
end
local MainStack = Roact.Component:extend("MainStack")
function MainStack:render()
return Roact.createElement("Frame")
end
local LoginStack = Roact.Component:extend("LoginStack")
function LoginStack:render()
return Roact.createElement("Frame")
end
MainStack.router = StackRouter({
{Home = Screen},
{Profile = Screen}
})
LoginStack.router = StackRouter({
{Form = Screen},
{ForgotPassword = Screen}
})
local router = SwitchRouter({
{Home = Screen},
{Login = LoginStack},
{Main = MainStack}
},{ initialRouteName = "Login" })
local helper = getRouterTestHelper(router)
local navigateTo = helper.navigateTo
local getSubState = helper.getSubState
expect(getSubState(1).routeName).to.equal("Login")
navigateTo("Home")
expect(getSubState(1).routeName).to.equal("Home")
end)
it("does not error for a nested navigate action in an uninitialized history router", function()
local helper = getRouterTestHelper(getExampleRouter({
backBehavior = "history",
}), {skipInitializeState = true})
local navigateTo = helper.navigateTo
local getSubState = helper.getSubState
navigateTo("B", {
action = NavigationActions.navigate({ routeName = "B2" }),
})
expect(getSubState(1).routeName).to.equal("B")
expect(getSubState(2).routeName).to.equal("B2")
end)
end)
end
@@ -0,0 +1,56 @@
return function()
local TabRouter = require(script.Parent.Parent.TabRouter)
local NavigationActions = require(script.Parent.Parent.Parent.NavigationActions)
-- NOTE: Most functional tests are covered by SwitchRouter.spec.lua
-- We just check that we can mount a basic case, and check that our custom
-- defaults for resetOnBlur and backBehavior work as expected.
it("should return a component that matches the given route name", function()
local testComponent = function() end
local router = TabRouter({
{ Foo = { screen = testComponent } },
})
local component = router.getComponentForRouteName("Foo")
expect(component).to.equal(testComponent)
end)
it("should not reset state for deactivated route", function()
local router = TabRouter({
{ Foo = { render = function() end } },
{ Bar = { render = function() end } },
})
local testParams = { a = 1 }
local initialState = {
routes = {
{ routeName = "Foo", params = testParams },
{ routeName = "Bar" },
},
index = 1,
}
local state = router.getStateForAction(NavigationActions.navigate({ routeName = "Bar" }), initialState)
expect(state.routes[1].params).to.equal(testParams)
end)
it("should go back to initial route index", function()
local router = TabRouter({
{ Foo = { render = function() end } },
{ Bar = { render = function() end } },
})
local prevState = {
routes = {
{ routeName = "Foo" },
{ routeName = "Bar" },
},
index = 2,
}
local newState = router.getStateForAction(NavigationActions.back(), prevState)
expect(newState.index).to.equal(1)
end)
end
@@ -0,0 +1,895 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/72e8160537954af40f1b070aa91ef45fc02bba69/packages/core/src/routers/__tests__/TabRouter.test.js
return function()
local Root = script.Parent.Parent.Parent
local Packages = Root.Parent
local Roact = require(Packages.Roact)
local BackBehavior = require(Root.BackBehavior)
local NavigationActions = require(Root.NavigationActions)
local expectDeepEqual = require(Root.utils.expectDeepEqual)
local TabRouter = require(script.Parent.Parent.TabRouter)
local BareLeafRouteConfig = {
screen = function()
return Roact.createElement("Frame")
end,
}
local INIT_ACTION = { type = NavigationActions.Init }
describe("TabRouter", function()
it("Handles basic tab logic", function()
local function ScreenA()
return Roact.createElement("Frame")
end
local function ScreenB()
return Roact.createElement("Frame")
end
local router = TabRouter({
{ Foo = { screen = ScreenA } },
{ Bar = { screen = ScreenB } },
})
local state = router.getStateForAction({
type = NavigationActions.Init,
})
local expectedState = {
index = 1,
routes = {
{ key = "Foo", routeName = "Foo" },
{ key = "Bar", routeName = "Bar" },
},
}
expectDeepEqual(state, expectedState)
local state2 = router.getStateForAction({
type = NavigationActions.Navigate,
routeName = "Bar",
}, state)
local expectedState2 = {
index = 2,
routes = {
{ key = "Foo", routeName = "Foo" },
{ key = "Bar", routeName = "Bar" },
},
}
expectDeepEqual(state2, expectedState2)
expect(router.getComponentForState(expectedState)).to.equal(ScreenA)
expect(router.getComponentForState(expectedState2)).to.equal(ScreenB)
local state3 = router.getStateForAction({
type = NavigationActions.Navigate,
routeName = "Bar",
}, state2)
expect(state3).to.equal(nil)
end)
it("Handles getScreen", function()
local function ScreenA()
return Roact.createElement("Frame")
end
local function ScreenB()
return Roact.createElement("Frame")
end
local router = TabRouter({
{ Foo = { getScreen = function() return ScreenA end } },
{ Bar = { getScreen = function() return ScreenB end } },
})
local state = router.getStateForAction({
type = NavigationActions.Init,
})
local expectedState = {
index = 1,
routes = {
{ key = "Foo", routeName = "Foo" },
{ key = "Bar", routeName = "Bar" },
},
}
expectDeepEqual(state, expectedState)
local state2 = router.getStateForAction({
type = NavigationActions.Navigate,
routeName = "Bar",
}, state)
local expectedState2 = {
index = 2,
routes = {
{ key = "Foo", routeName = "Foo" },
{ key = "Bar", routeName = "Bar" },
},
}
expectDeepEqual(state2, expectedState2)
expect(router.getComponentForState(expectedState)).to.equal(ScreenA)
expect(router.getComponentForState(expectedState2)).to.equal(ScreenB)
local state3 = router.getStateForAction({
type = NavigationActions.Navigate,
routeName = "Bar",
}, state2)
expect(state3).to.equal(nil)
end)
it("Can set the initial tab", function()
local router = TabRouter({
{ Foo = BareLeafRouteConfig },
{ Bar = BareLeafRouteConfig },
}, {
initialRouteName = "Bar",
})
local state = router.getStateForAction({ type = NavigationActions.Init })
expectDeepEqual(state, {
index = 2,
routes = {
{ key = "Foo", routeName = "Foo" },
{ key = "Bar", routeName = "Bar" },
},
})
end)
it("Can set the initial params", function()
local router = TabRouter({
{ Foo = BareLeafRouteConfig },
{ Bar = BareLeafRouteConfig },
}, {
initialRouteName = "Bar",
initialRouteParams = { name = "Qux" },
})
local state = router.getStateForAction({
type = NavigationActions.Init,
})
expectDeepEqual(state, {
index = 2,
routes = {
{ key = "Foo", routeName = "Foo" },
{ key = "Bar", routeName = "Bar", params = { name = "Qux" } },
},
})
end)
it("Handles the SetParams action", function()
local router = TabRouter({
{
Foo = {
screen = function() return Roact.createElement("Frame") end,
},
},
{
Bar = {
screen = function() return Roact.createElement("Frame") end,
},
},
})
local state2 = router.getStateForAction({
type = NavigationActions.SetParams,
params = { name = "Qux" },
key = "Foo",
})
expect(state2).to.be.a('table')
expectDeepEqual(state2.routes[1].params, {
name = "Qux",
})
end)
it("Handles the SetParams action for inactive routes", function()
local router = TabRouter({
{
Foo = {
screen = function() return Roact.createElement("Frame") end,
},
},
{
Bar = {
screen = function() return Roact.createElement("Frame") end,
},
},
}, {
initialRouteName = "Bar",
})
local initialState = {
index = 2,
routes = {
{
key = "RouteA",
routeName = "Foo",
params = { name = "InitialParam", other = "Unchanged" },
},
{ key = "RouteB", routeName = "Bar", params = {} },
},
}
local state = router.getStateForAction({
type = NavigationActions.SetParams,
params = { name = "NewParam" },
key = "RouteA",
}, initialState)
expect(state.index).to.equal(2)
expectDeepEqual(state.routes[1].params, {
name = "NewParam",
other = "Unchanged",
})
end)
it("getStateForAction returns null when navigating to same tab", function()
local router = TabRouter({
{ Foo = BareLeafRouteConfig },
{ Bar = BareLeafRouteConfig },
}, {
initialRouteName = "Bar",
})
local state = router.getStateForAction({
type = NavigationActions.Init,
})
local state2 = router.getStateForAction({
type = NavigationActions.Navigate,
routeName = "Bar",
}, state)
expect(state2).to.equal(nil)
end)
it("getStateForAction returns initial navigate", function()
local router = TabRouter({
{ Foo = BareLeafRouteConfig },
{ Bar = BareLeafRouteConfig },
})
local state = router.getStateForAction({
type = NavigationActions.Navigate,
routeName = "Foo",
})
expect(state and state.index).to.equal(1)
end)
-- deviation: Router.getActionForPathAndParams not implemented yet.
itSKIP("Handles nested tabs and nested actions", function()
local ChildTabNavigator = Roact.Component:extend("ChildTabNavigator")
function ChildTabNavigator:render()
return Roact.createElement("Frame")
end
ChildTabNavigator.router = TabRouter({
{ Foo = BareLeafRouteConfig },
{ Bar = BareLeafRouteConfig },
})
local router = TabRouter({
{ Foo = BareLeafRouteConfig },
{ Baz = { screen = ChildTabNavigator } },
{ Boo = BareLeafRouteConfig },
})
local action = router.getActionForPathAndParams("Baz/Bar", { foo = "42" })
local navAction = {
type = NavigationActions.Navigate,
routeName = "Baz",
params = { foo = "42" },
action = {
type = NavigationActions.Navigate,
routeName = "Bar",
params = { foo = "42" },
},
}
expectDeepEqual(action, navAction)
local state = router.getStateForAction(navAction)
expectDeepEqual(state, {
index = 2,
routes = {
{ key = "Foo", routeName = "Foo" },
{
index = 2,
key = "Baz",
routeName = "Baz",
params = { foo = "42" },
routes = {
{ key = "Foo", routeName = "Foo" },
{
key = "Bar",
routeName = "Bar",
params = { foo = "42" },
},
},
},
{ key = "Boo", routeName = "Boo" },
},
})
end)
it("Handles passing params to nested tabs", function()
local ChildTabNavigator = Roact.Component:extend("ChildTabNavigator")
function ChildTabNavigator:render()
return Roact.createElement("Frame")
end
ChildTabNavigator.router = TabRouter({
{ Boo = BareLeafRouteConfig },
{ Bar = BareLeafRouteConfig },
})
local router = TabRouter({
{ Foo = BareLeafRouteConfig },
{ Baz = { screen = ChildTabNavigator } },
})
local navAction = {
type = NavigationActions.Navigate,
routeName = "Baz",
}
local state = router.getStateForAction(navAction)
expectDeepEqual(state, {
index = 2,
routes = {
{ key = "Foo", routeName = "Foo" },
{
index = 1,
key = "Baz",
routeName = "Baz",
routes = {
{ key = "Boo", routeName = "Boo" },
{ key = "Bar", routeName = "Bar" },
},
},
},
})
-- Ensure that navigating back and forth doesn't overwrite
state = router.getStateForAction(
{ type = NavigationActions.Navigate, routeName = "Bar" },
state
)
state = router.getStateForAction(
{ type = NavigationActions.Navigate, routeName = "Boo" },
state
)
expectDeepEqual(state and state.routes[2], {
index = 1,
key = "Baz",
routeName = "Baz",
routes = {
{ key = "Boo", routeName = "Boo" },
{ key = "Bar", routeName = "Bar" },
},
})
end)
it("Handles initial deep linking into nested tabs", function()
local ChildTabNavigator = Roact.Component:extend("ChildTabNavigator")
function ChildTabNavigator:render()
return Roact.createElement("Frame")
end
ChildTabNavigator.router = TabRouter({
{ Foo = BareLeafRouteConfig },
{ Bar = BareLeafRouteConfig },
})
local router = TabRouter({
{ Foo = BareLeafRouteConfig },
{ Baz = { screen = ChildTabNavigator } },
{ Boo = BareLeafRouteConfig },
})
local state = router.getStateForAction({
type = NavigationActions.Navigate,
routeName = "Bar",
})
expectDeepEqual(state, {
index = 2,
routes = {
{ key = "Foo", routeName = "Foo" },
{
index = 2,
key = "Baz",
routeName = "Baz",
routes = {
{ key = "Foo", routeName = "Foo" },
{ key = "Bar", routeName = "Bar" },
},
},
{ key = "Boo", routeName = "Boo" },
},
})
local state2 = router.getStateForAction(
{ type = NavigationActions.Navigate, routeName = "Foo" },
state
)
expectDeepEqual(state2, {
index = 2,
routes = {
{ key = "Foo", routeName = "Foo" },
{
index = 1,
key = "Baz",
routeName = "Baz",
routes = {
{ key = "Foo", routeName = "Foo" },
{ key = "Bar", routeName = "Bar" },
},
},
{ key = "Boo", routeName = "Boo" },
},
})
local state3 = router.getStateForAction(
{ type = NavigationActions.Navigate, routeName = "Foo" },
state2
)
expect(state3).to.equal(nil)
end)
it("Handles linking across of deeply nested tabs", function()
local ChildNavigator0 = Roact.Component:extend("ChildNavigator0")
function ChildNavigator0:render()
return Roact.createElement("Frame")
end
ChildNavigator0.router = TabRouter({
{ Boo = BareLeafRouteConfig },
{ Baz = BareLeafRouteConfig },
})
local ChildNavigator1 = Roact.Component:extend("ChildNavigator1")
function ChildNavigator1:render()
return Roact.createElement("Frame")
end
ChildNavigator1.router = TabRouter({
{ Zoo = BareLeafRouteConfig },
{ Zap = BareLeafRouteConfig },
})
local MidNavigator = Roact.Component:extend("MidNavigator")
function MidNavigator:render()
return Roact.createElement("Frame")
end
MidNavigator.router = TabRouter({
{ Fee = { screen = ChildNavigator0 } },
{ Bar = { screen = ChildNavigator1 } },
})
local router = TabRouter({
{ Foo = { screen = MidNavigator } },
{ Gah = BareLeafRouteConfig },
})
local state = router.getStateForAction(INIT_ACTION)
expectDeepEqual(state, {
index = 1,
routes = {
{
index = 1,
key = "Foo",
routeName = "Foo",
routes = {
{
index = 1,
key = "Fee",
routeName = "Fee",
routes = {
{ key = "Boo", routeName = "Boo" },
{ key = "Baz", routeName = "Baz" },
},
},
{
index = 1,
key = "Bar",
routeName = "Bar",
routes = {
{ key = "Zoo", routeName = "Zoo" },
{ key = "Zap", routeName = "Zap" },
},
},
},
},
{ key = "Gah", routeName = "Gah" },
},
})
local state2 = router.getStateForAction(
{ type = NavigationActions.Navigate, routeName = "Zap" },
state
)
expectDeepEqual(state2, {
index = 1,
routes = {
{
index = 2,
key = "Foo",
routeName = "Foo",
routes = {
{
index = 1,
key = "Fee",
routeName = "Fee",
routes = {
{ key = "Boo", routeName = "Boo" },
{ key = "Baz", routeName = "Baz" },
},
},
{
index = 2,
key = "Bar",
routeName = "Bar",
routes = {
{ key = "Zoo", routeName = "Zoo" },
{ key = "Zap", routeName = "Zap" },
},
},
},
},
{ key = "Gah", routeName = "Gah" },
},
})
local state3 = router.getStateForAction(
{ type = NavigationActions.Navigate, routeName = "Zap" },
state2
)
expect(state3).to.equal(nil)
local state4 = router.getStateForAction({
type = NavigationActions.Navigate,
routeName = "Foo",
action = {
type = NavigationActions.Navigate,
routeName = "Bar",
action = { type = NavigationActions.Navigate, routeName = "Zap" },
},
})
expectDeepEqual(state4, {
index = 1,
routes = {
{
index = 2,
key = "Foo",
routeName = "Foo",
routes = {
{
index = 1,
key = "Fee",
routeName = "Fee",
routes = {
{ key = "Boo", routeName = "Boo" },
{ key = "Baz", routeName = "Baz" },
},
},
{
index = 2,
key = "Bar",
routeName = "Bar",
routes = {
{ key = "Zoo", routeName = "Zoo" },
{ key = "Zap", routeName = "Zap" },
},
},
},
},
{ key = "Gah", routeName = "Gah" },
},
})
end)
-- deviation: Router.getActionForPathAndParams not implemented yet.
itSKIP("Handles path configuration", function()
local function ScreenA()
return Roact.createElement("Frame")
end
local function ScreenB()
return Roact.createElement("Frame")
end
local router = TabRouter({
{ Foo = { path = "f", screen = ScreenA } },
{ Bar = { path = "b/:great", screen = ScreenB } },
})
local params = { foo = "42" }
local action = router.getActionForPathAndParams("b/anything", params)
local expectedAction = {
params = {
foo = "42",
great = "anything",
},
routeName = "Bar",
type = NavigationActions.Navigate,
}
expectDeepEqual(action, expectedAction)
local state = router.getStateForAction({ type = NavigationActions.Init })
local expectedState = {
index = 1,
routes = {
{ key = "Foo", routeName = "Foo" },
{ key = "Bar", routeName = "Bar" },
},
}
expect(state).to.equal(expectedState)
local state2 = router.getStateForAction(expectedAction, state)
local expectedState2 = {
index = 2,
routes = {
{ key = "Foo", routeName = "Foo", params = nil },
{
key = "Bar",
routeName = "Bar",
params = { foo = "42", great = "anything" },
},
},
}
expectDeepEqual(state2, expectedState2)
expect(router.getComponentForState(expectedState)).to.equal(ScreenA)
expect(router.getComponentForState(expectedState2)).to.equal(ScreenB)
expect(router.getPathAndParamsForState(expectedState).path).to.equal("f")
expect(router.getPathAndParamsForState(expectedState2).path).to.equal("b/anything")
end)
-- deviation: Router.getActionForPathAndParams not implemented yet.
itSKIP("Handles default configuration", function()
local function ScreenA()
return Roact.createElement("Frame")
end
local function ScreenB()
return Roact.createElement("Frame")
end
local router = TabRouter({
{ Foo = { path = "", screen = ScreenA } },
{ Bar = { path = "b", screen = ScreenB } },
})
local action = router.getActionForPathAndParams("", { foo = "42" })
expectDeepEqual(action, {
params = { foo = "42" },
routeName = "Foo",
type = NavigationActions.Navigate,
})
end)
-- deviation: Router.getActionForPathAndParams not implemented yet.
itSKIP("Gets deep path", function()
local ScreenA = Roact.Component:extend("ScreenA")
function ScreenA:render()
return Roact.createElement("Frame")
end
local function ScreenB()
return Roact.createElement("Frame")
end
ScreenA.router = TabRouter({
{ Baz = { screen = ScreenB } },
{ Boo = { screen = ScreenB } },
})
local router = TabRouter({
{ Foo = { path = "f", screen = ScreenA } },
{ Bar = { screen = ScreenB } },
})
local state = {
index = 1,
routes = {
{
index = 2,
key = "Foo",
routeName = "Foo",
routes = {
{ key = "Boo", routeName = "Boo" },
{ key = "Baz", routeName = "Baz" },
},
},
{ key = "Bar", routeName = "Bar" },
},
}
local path = router.getPathAndParamsForState(state).path
expect(path).to.equal("f/Baz")
end)
it("Can navigate to other tab (no router) with params", function()
local function ScreenA()
return Roact.createElement("Frame")
end
local function ScreenB()
return Roact.createElement("Frame")
end
local router = TabRouter({
{ a = { screen = ScreenA } },
{ b = { screen = ScreenB } },
})
local state0 = router.getStateForAction(INIT_ACTION)
expectDeepEqual(state0, {
index = 1,
routes = {
{ key = "a", routeName = "a" },
{ key = "b", routeName = "b" },
},
})
local params = { key = "value" }
local state1 = router.getStateForAction({
type = NavigationActions.Navigate,
routeName = "b",
params = params,
}, state0)
expectDeepEqual(state1, {
index = 2,
routes = {
{ key = "a", routeName = "a" },
{ key = "b", routeName = "b", params = params },
},
})
end)
it("Back actions are not propagated to inactive children", function()
local function ScreenA()
return Roact.createElement("Frame")
end
local function ScreenB()
return Roact.createElement("Frame")
end
local function ScreenC()
return Roact.createElement("Frame")
end
local InnerNavigator = Roact.Component:extend("InnerNavigator")
function InnerNavigator:render()
return Roact.createElement("Frame")
end
InnerNavigator.router = TabRouter({
{ a = { screen = ScreenA } },
{ b = { screen = ScreenB } },
})
local router = TabRouter({
{ inner = { screen = InnerNavigator } },
{ c = { screen = ScreenC } },
}, {
backBehavior = BackBehavior.None,
})
local state0 = router.getStateForAction(INIT_ACTION)
local state1 = router.getStateForAction(
{ type = NavigationActions.Navigate, routeName = "b" },
state0
)
local state2 = router.getStateForAction(
{ type = NavigationActions.Navigate, routeName = "c" },
state1
)
local state3 = router.getStateForAction(
{ type = NavigationActions.Back },
state2
)
expectDeepEqual(state3, state2)
end)
it("Back behavior initialRoute works", function()
local function ScreenA()
return Roact.createElement("Frame")
end
local function ScreenB()
return Roact.createElement("Frame")
end
local router = TabRouter({
{ a = { screen = ScreenA } },
{ b = { screen = ScreenB } },
})
local state0 = router.getStateForAction(INIT_ACTION)
local state1 = router.getStateForAction(
{ type = NavigationActions.Navigate, routeName = "b" },
state0
)
local state2 = router.getStateForAction(
{ type = NavigationActions.Back },
state1
)
expectDeepEqual(state2, state0)
end)
it("Inner actions are only unpacked if the current tab matches", function()
local PlainScreen = function()
return Roact.createElement("Frame")
end
local ScreenA = Roact.Component:extend("ScreenA")
function ScreenA:render()
return Roact.createElement("Frame")
end
local ScreenB = Roact.Component:extend("ScreenB")
function ScreenB:render()
return Roact.createElement("Frame")
end
ScreenB.router = TabRouter({
{ Baz = { screen = PlainScreen } },
{ Zoo = { screen = PlainScreen } },
})
ScreenA.router = TabRouter({
{ Bar = { screen = PlainScreen } },
{ Boo = { screen = ScreenB } },
})
local router = TabRouter({
{ Foo = { screen = ScreenA } },
})
local screenApreState = {
index = 1,
key = "Foo",
routeName = "Foo",
routes = {
{ key = "Bar", routeName = "Bar" },
},
}
local preState = {
index = 1,
routes = { screenApreState },
}
local function comparable(state)
local result = {}
if typeof(state.routeName) == "string" then
result.routeName = state.routeName
end
if typeof(state.routes) == 'table' then
result.routes = {}
for i=1, #state.routes do
result.routes[i] = comparable(state.routes[i])
end
end
return result
end
local action = NavigationActions.navigate({
routeName = "Boo",
action = NavigationActions.navigate({ routeName = "Zoo" }),
})
local expectedState = ScreenA.router.getStateForAction(action, screenApreState)
local state = router.getStateForAction(action, preState)
local innerState = state and state.routes[1] or state
expect(innerState.routes[2].index).to.equal(2)
expectDeepEqual(
expectedState and comparable(expectedState),
innerState and comparable(innerState)
)
local noMatchAction = NavigationActions.navigate({
routeName = "Qux",
action = NavigationActions.navigate({ routeName = "Zoo" }),
})
local expectedState2 = ScreenA.router.getStateForAction(noMatchAction, screenApreState)
local state2 = router.getStateForAction(noMatchAction, preState)
local innerState2 = state2 and state2.routes[1] or state2
expect(innerState2.routes[2].index).to.equal(1)
expectDeepEqual(
expectedState2 and comparable(expectedState2),
innerState2 and comparable(innerState2)
)
end)
end)
end
@@ -0,0 +1,37 @@
return function()
local createConfigGetter = require(script.Parent.Parent.createConfigGetter)
it("should return a function", function()
local result = createConfigGetter({}, {})
expect(result).to.be.a("function")
end)
it("should override default config with component-specific config", function()
local getScreenOptions = createConfigGetter({
Home = {
screen = {
render = function() end,
navigationOptions = { title = "ComponentHome" },
},
},
defaultNavigationOptions = { title = "DefaultTitle" },
})
expect(getScreenOptions({ state = { routeName = "Home" } }).title).to.equal("ComponentHome")
end)
it("should override component-specific config with route-specific config", function()
local getScreenOptions = createConfigGetter({
Home = {
screen = {
render = function() end,
navigationOptions = { title = "ComponentHome" },
},
navigationOptions = { title = "RouteHome" },
},
defaultNavigationOptions = { title = "DefaultTitle" },
})
expect(getScreenOptions({ state = { routeName = "Home" } }).title).to.equal("RouteHome")
end)
end
@@ -0,0 +1,102 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/72e8160537954af40f1b070aa91ef45fc02bba69/packages/core/src/routers/__tests__/createConfigGetter.test.js
return function()
local createConfigGetter = require(script.Parent.Parent.createConfigGetter)
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
it("should get config for screen", function()
local HomeScreen = Roact.Component:extend("HomeScreen")
HomeScreen.navigationOptions = function(props)
local username = props.navigation.state.params and
props.navigation.state.params.user or "anonymous"
return {
title = string.format("Welcome %s", username),
gesturesEnabled = true,
}
end
function HomeScreen:render() return nil end
local SettingsScreen = Roact.Component:extend("SettingsScreen")
SettingsScreen.navigationOptions = {
title = "Settings!!!",
gesturesEnabled = false,
}
function SettingsScreen:render() return nil end
local NotificationScreen = Roact.Component:extend("NotificationScreen")
NotificationScreen.navigationOptions = function(props)
local gesturesEnabled = true
if props.navigation.state.params then
gesturesEnabled = not props.navigation.state.params.fullscreen
end
return {
title = "42",
gesturesEnabled = gesturesEnabled
}
end
local getScreenOptions = createConfigGetter({
Home = { screen = HomeScreen },
Settings = { screen = SettingsScreen },
Notifications = {
screen = NotificationScreen,
navigationOptions = {
title = "10 new notifications",
}
}
})
local routes = {
{ key = "A", routeName = "Home", },
{ key = "B", routeName = "Home", params = { user = "jane"} },
{ key = "C", routeName = "Settings", },
{ key = "D", routeName = "Notifications", },
{ key = "E", routeName = "Notifications", params = { fullscreen = true } },
}
expect(getScreenOptions({ state = routes[1] }, {}).title)
.to.equal("Welcome anonymous")
expect(getScreenOptions({ state = routes[2] }, {}).title)
.to.equal("Welcome jane")
expect(getScreenOptions({ state = routes[1] }, {}).gesturesEnabled)
.to.equal(true)
expect(getScreenOptions({ state = routes[3] }, {}).title)
.to.equal("Settings!!!")
expect(getScreenOptions({ state = routes[3] }, {}).gesturesEnabled)
.to.equal(false)
expect(getScreenOptions({ state = routes[4] }, {}).title)
.to.equal("10 new notifications")
expect(getScreenOptions({ state = routes[4] }, {}).gesturesEnabled)
.to.equal(true)
expect(getScreenOptions({ state = routes[5] }, {}).gesturesEnabled)
.to.equal(false)
end)
it("should throw if the route does not exist", function()
local HomeScreen = Roact.Component:extend("HomeScreen")
HomeScreen.navigationOptions = {
title = "Home screen",
gesturesEnabled = true,
}
local getScreenOptions = createConfigGetter({
Home = { screen = HomeScreen },
})
local routes = {{ key = "B", routeName = "Settings" }}
expect(function()
getScreenOptions({ state = routes[1] }, {})
end).to.throw("There is no route defined for key Settings.\nMust be one of: 'Home'")
end)
end
@@ -0,0 +1,101 @@
return function()
local getNavigationActionCreators = require(script.Parent.Parent.getNavigationActionCreators)
local NavigationActions = require(script.Parent.Parent.Parent.NavigationActions)
local function expectError(functor, msg)
local status, err = pcall(functor)
expect(status).to.equal(false)
expect(string.find(err, msg)).to.never.equal(nil)
end
it("should return a table with correct functions when called", function()
local result = getNavigationActionCreators()
expect(type(result.goBack)).to.equal("function")
expect(type(result.navigate)).to.equal("function")
expect(type(result.setParams)).to.equal("function")
end)
describe("goBack tests", function()
it("should return a Back action when called", function()
local result = getNavigationActionCreators().goBack("theKey")
expect(result.type).to.equal(NavigationActions.Back)
expect(result.key).to.equal("theKey")
end)
it("should throw when route.key is not a string", function()
expectError(function()
getNavigationActionCreators({ key = 5 }).goBack()
end, "%.goBack%(%): key should be a string")
end)
it("should fall back to route.key if key is not provided", function()
local result = getNavigationActionCreators({ key = "routeKey" }).goBack()
expect(result.key).to.equal("routeKey")
end)
it("should override route.key if key is provided", function()
local result = getNavigationActionCreators({ key = "routeKey" }).goBack("theKey")
expect(result.key).to.equal("theKey")
end)
end)
describe("navigate tests", function()
it("should return a Navigate action when called", function()
local theParams = {}
local childAction = {}
local result = getNavigationActionCreators().navigate("theRoute", theParams, childAction)
expect(result.type).to.equal(NavigationActions.Navigate)
expect(result.routeName).to.equal("theRoute")
expect(result.params).to.equal(theParams)
expect(result.action).to.equal(childAction)
end)
it("should return a navigate action with matching properties when called with a table", function()
local testNavigateTo = {
routeName = "theRoute",
params = {},
action = {},
}
local result = getNavigationActionCreators().navigate(testNavigateTo)
expect(result.type).to.equal(NavigationActions.Navigate)
expect(result.routeName).to.equal("theRoute")
expect(result.params).to.equal(testNavigateTo.params)
expect(result.action).to.equal(testNavigateTo.action)
end)
it("should throw when navigateTo is not a valid type", function()
expectError(function()
getNavigationActionCreators().navigate(5)
end, "%.navigate%(%): navigateTo must be a string or table")
end)
it("should throw when params is provided with a table navigateTo", function()
expectError(function()
getNavigationActionCreators().navigate({}, {})
end, "%.navigate%(%): params can only be provided with a string navigateTo value")
end)
it("should throw when action is provided with a table navigateTo", function()
expectError(function()
getNavigationActionCreators().navigate({}, nil, {})
end, "%.navigate%(%): child action can only be provided with a string navigateTo value")
end)
end)
describe("setParams tests", function()
it("should return a SetParams action when called", function()
local theParams = {}
local result = getNavigationActionCreators({ key = "theKey" }).setParams(theParams)
expect(result.type).to.equal(NavigationActions.SetParams)
expect(result.key).to.equal("theKey")
expect(result.params).to.equal(theParams)
end)
it("should throw when called by a root navigator", function()
expectError(function()
getNavigationActionCreators({}).setParams({})
end, "%.setParams%(%): cannot be called by the root navigator")
end)
end)
end
@@ -0,0 +1,80 @@
return function()
local getScreenForRouteName = require(script.Parent.Parent.getScreenForRouteName)
it("should throw for invalid arg types", function()
expect(function()
getScreenForRouteName("", "myRoute")
end).to.throw("routeConfigs must be a table")
expect(function()
getScreenForRouteName({}, 5)
end).to.throw("routeName must be a string")
end)
it("should throw if requested route is not present within table", function()
local function shouldThrow()
getScreenForRouteName({
notMyRoute = function() return "foo" end
}, "myRoute")
end
expect(shouldThrow).to.throw(
"There is no route defined for key myRoute.\nMust be one of: 'notMyRoute'"
)
end)
it("should return raw table if screen and getScreen are not props", function()
local screenComponent = { render = function() return nil end }
local result = getScreenForRouteName({
myRoute = screenComponent
}, "myRoute")
expect(result).to.equal(screenComponent)
end)
it("should return screen prop if it is set in route data table", function()
local screenComponent = { render = function() return nil end }
local result = getScreenForRouteName({
myRoute = {
screen = screenComponent
}
}, "myRoute")
expect(result).to.equal(screenComponent)
end)
it("should return object returned by getScreen function if object is valid Roact element", function()
local screenComponent = { render = function() return nil end }
local result = getScreenForRouteName({
myRoute = {
getScreen = function() return screenComponent end
}
}, "myRoute")
expect(result).to.equal(screenComponent)
end)
it("should throw if getScreen does not return a valid Roact element", function()
local errorExpected = "The getScreen defined for route 'myRoute' didn't return a valid " ..
"screen or navigator.\n\n"
expect(function()
getScreenForRouteName({
myRoute = {
getScreen = function() return nil end
}
}, "myRoute")
end).to.throw(errorExpected)
end)
it("should throw if screen is not a valid Roact element", function()
expect(function()
getScreenForRouteName({
myRoute = {
screen = 5,
}
}, "myRoute")
end).to.throw("screen for key 'myRoute' must be a valid Roact component.")
end)
end
@@ -0,0 +1,95 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/fcd7d83c4c33ad1fa508c8cfe687d2fa259bfc2c/packages/core/src/routers/__tests__/routerTestHelper.js
local Root = script.Parent.Parent.Parent
local Packages = Root.Parent
local Cryo = require(Packages.Cryo)
local StackActions = require(Root.routers.StackActions)
local SwitchActions = require(Root.routers.SwitchActions)
local NavigationActions = require(Root.NavigationActions)
local defaultOptions = { skipInitializeState = false }
local function getSubStateRecursive(state, level)
level = level or 1
if level == 0 then
return state
else
local directSubState = state.routes[state.index]
return getSubStateRecursive(directSubState, level -1 )
end
end
local function getRouterTestHelper(router, options)
options = options or defaultOptions
local state = nil
if (not options.skipInitializeState) then
state = router.getStateForAction({
type = NavigationActions.Init,
})
end
local function applyAction(action)
state = router.getStateForAction(action, state)
end
local function navigateTo(routeName, otherActionAttributes)
otherActionAttributes = otherActionAttributes or {}
return applyAction(Cryo.Dictionary.join({
type = NavigationActions.Navigate,
routeName = routeName,
}, otherActionAttributes))
end
local function jumpTo(routeName, otherActionAttributes)
otherActionAttributes = otherActionAttributes or {}
return applyAction(Cryo.Dictionary.join({
type = SwitchActions.JumpTo,
routeName = routeName,
}, otherActionAttributes))
end
local function back(key)
return applyAction({
type = NavigationActions.Back,
key = key,
})
end
local function pop()
return applyAction({
type = StackActions.Pop,
})
end
local function popToTop()
return applyAction({
type = StackActions.PopToTop,
})
end
local function getState()
return state
end
local function getSubState(level)
level = level or 1
return getSubStateRecursive(state, level)
end
return{
applyAction = applyAction,
navigateTo = navigateTo,
jumpTo = jumpTo,
back = back,
pop = pop,
popToTop = popToTop,
getState = getState,
getSubState = getSubState,
}
end
return getRouterTestHelper
@@ -0,0 +1,129 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local validateRouteConfigArray = require(script.Parent.Parent.validateRouteConfigArray)
local TestComponent = Roact.Component:extend("TestComponent")
function TestComponent:render()
return nil
end
it("should throw if routeConfigs is not a table", function()
expect(function()
validateRouteConfigArray(5)
end).to.throw("routeConfigs must be an array table")
end)
it("should throw if routeConfigs is empty", function()
expect(function()
validateRouteConfigArray({})
end).to.throw("Please specify at least one route when configuring a navigator")
end)
it("should throw if routeConfigs contains an invalid Roact element", function()
local error = "The component for route 'myRoute' must be a Roact Function/Stateful"
.. " component or table with 'getScreen'.getScreen function must return Roact"
.. " Function/Stateful component"
expect(function()
validateRouteConfigArray({
{ myRoute = 5 },
})
end).to.throw(error)
end)
it("should throw if getScreen returns invalid Roact element", function()
local error = "The component for route 'myRoute' must be a Roact Function/Stateful"
.. " component or table with 'getScreen'.getScreen function must return Roact"
.. " Function/Stateful component"
expect(function()
validateRouteConfigArray({
{ myRoute = { getScreen = function() end } },
})
end).to.throw(error)
end)
it("should throw when both screen and getScreen are provided for same component", function()
expect(function()
validateRouteConfigArray({
{myRoute = {
screen = "TheScreen",
getScreen = function() return TestComponent end,
}}
})
end).to.throw("Route 'myRoute' should provide 'screen' or 'getScreen', but not both")
end)
it("should throw for a simple table where screen is not a Roact Function/Stateful component", function()
local error = "The component for route 'myRoute' must be a Roact Function/Stateful"
.. " component or table with 'getScreen'.getScreen function must return Roact"
.. " Function/Stateful component"
expect(function()
validateRouteConfigArray({
{ myRoute = { screen = {} } },
})
end).to.throw(error)
end)
it("should throw for a non-function getScreen", function()
local error = "The component for route 'myRoute' must be a Roact Function/Stateful"
.. " component or table with 'getScreen'.getScreen function must return"
.. " Roact Function/Stateful component"
expect(function()
validateRouteConfigArray({
{ myRoute = { getScreen = 5 } },
})
end).to.throw(error)
end)
it("should throw for a Host Component", function()
local error = "The component for route 'myRoute' must be a Roact Function/Stateful"
.. " component or table with 'getScreen'.getScreen function must return Roact"
.. " Function/Stateful component"
expect(function()
validateRouteConfigArray({
{ myRoute = { aFrame = "Frame" } },
})
end).to.throw(error)
end)
it("should throw if routeConfig is a map", function()
local key = "basicComponentRoute"
local error = ("routeConfigs must be an array table (found non-number key %q of type %q"):format(
key,
type(key)
)
expect(function()
validateRouteConfigArray({
[key] = TestComponent,
})
end).to.throw(error)
end)
it("should throw if there is more than one route in each array entry", function()
local error = "only one route must be defined in each entry (found multiple at index 1)"
expect(function()
validateRouteConfigArray({
{ aRouteName = TestComponent, anotherRoute = TestComponent },
})
end).to.throw(error)
end)
it("should pass for valid basic routeConfigs", function()
validateRouteConfigArray({
{ basicComponentRoute = TestComponent },
{ functionalComponentRoute = function() end },
})
end)
it("should pass for valid screen prop type routeConfigs", function()
validateRouteConfigArray({
{ basicComponentRoute = { screen = TestComponent } },
{ functionalComponentRoute = { screen = function() end } },
})
end)
it("should pass for valid getScreen route configs", function()
validateRouteConfigArray({
{ getScreenRoute = { getScreen = function() return TestComponent end } },
})
end)
end
@@ -0,0 +1,99 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local validateRouteConfigMap = require(script.Parent.Parent.validateRouteConfigMap)
local TestComponent = Roact.Component:extend("TestComponent")
function TestComponent:render()
return nil
end
local INVALID_COMPONENT_MESSAGE = "The component for route 'myRoute' must be a Roact" ..
" component or table with 'getScreen'."
it("should throw if routeConfigs is not a table", function()
expect(function()
validateRouteConfigMap(5)
end).to.throw("routeConfigs must be a table")
end)
it("should throw if routeConfigs is empty", function()
expect(function()
validateRouteConfigMap({})
end).to.throw("Please specify at least one route when configuring a navigator.")
end)
it("should throw if routeConfigs contains an invalid Roact element", function()
expect(function()
validateRouteConfigMap({
myRoute = 5,
})
end).to.throw()
end)
it("should throw when both screen and getScreen are provided for same component", function()
expect(function()
validateRouteConfigMap({
myRoute = {
screen = "TheScreen",
getScreen = function() return TestComponent end,
}
})
end).to.throw("Route 'myRoute' should declare a screen or a getScreen, not both.")
end)
it("should throw for a simple table where screen is not a Roact component", function()
expect(function()
validateRouteConfigMap({
myRoute = {
screen = {},
}
})
end).to.throw(INVALID_COMPONENT_MESSAGE)
end)
it("should throw for a non-function getScreen", function()
expect(function()
validateRouteConfigMap({
myRoute = {
getScreen = 5
}
})
end).to.throw(INVALID_COMPONENT_MESSAGE)
end)
it("should throw for a Host Component", function()
expect(function()
validateRouteConfigMap({
myRoute = {
aFrame = "Frame"
}
})
end).to.throw(INVALID_COMPONENT_MESSAGE)
end)
it("should pass for valid basic routeConfigs", function()
validateRouteConfigMap({
basicComponentRoute = TestComponent,
functionalComponentRoute = function() end,
})
end)
it("should pass for valid screen prop type routeConfigs", function()
validateRouteConfigMap({
basicComponentRoute = {
screen = TestComponent,
},
functionalComponentRoute = {
screen = function() end,
},
})
end)
it("should pass for valid getScreen route configs", function()
validateRouteConfigMap({
getScreenRoute = {
getScreen = function() return TestComponent end,
}
})
end)
end
@@ -0,0 +1,25 @@
return function()
local validateScreenOptions = require(script.Parent.Parent.validateScreenOptions)
it("should not throw when there are no problems", function()
validateScreenOptions({ title = "foo" }, { routeName = "foo" })
end)
it("should throw error if no routeName is provided", function()
local status, err = pcall(function()
validateScreenOptions({ title = "bar" }, {})
end)
expect(status).to.equal(false)
expect(string.find(err, "route.routeName must be a string")).to.never.equal(nil)
end)
it("should throw error for options with function for title", function()
expect(function()
validateScreenOptions({
title = function() end,
}, { routeName = "foo" })
end).to.throw()
end)
end
@@ -0,0 +1,62 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/6390aacd07fd647d925dfec842a766c8aad5272f/packages/core/src/routers/createConfigGetter.js
local Cryo = require(script.Parent.Parent.Parent.Cryo)
local getScreenForRouteName = require(script.Parent.getScreenForRouteName)
local validateScreenOptions = require(script.Parent.validateScreenOptions)
local validate = require(script.Parent.Parent.utils.validate)
local function applyConfig(configurer, navigationOptions, configProps)
navigationOptions = navigationOptions or {}
local configurerType = type(configurer)
if configurerType == "function" then
return Cryo.Dictionary.join(
navigationOptions,
configurer(Cryo.Dictionary.join(configProps, {
navigationOptions = navigationOptions
}))
)
elseif configurerType == "table" then
return Cryo.Dictionary.join(navigationOptions, configurer)
else
return navigationOptions
end
end
return function(routeConfigs, navigatorScreenConfig)
return function(navigation, screenProps)
screenProps = screenProps or {}
local route = navigation.state
validate(type(route) == "table", "navigation.state must be a table")
validate(
type(route.routeName == "string"),
"Cannot get config because the route does not have a routeName."
)
local component = getScreenForRouteName(routeConfigs, route.routeName)
local routeConfig = routeConfigs[route.routeName]
local routeScreenConfig = nil
if routeConfig ~= component then
routeScreenConfig = routeConfig.navigationOptions
end
-- deviation: check if the component is a table, because it could be a
-- function and it can't be indexed in Lua.
local componentScreenConfig = type(component) == "table"
and component.navigationOptions or {}
local configOptions = {
navigation = navigation,
screenProps = screenProps,
-- deviation: no theme support
}
local outputConfig = applyConfig(navigatorScreenConfig, {}, configOptions)
outputConfig = applyConfig(componentScreenConfig, outputConfig, configOptions)
outputConfig = applyConfig(routeScreenConfig, outputConfig, configOptions)
validateScreenOptions(outputConfig, route)
return outputConfig
end
end
@@ -0,0 +1,42 @@
local NavigationActions = require(script.Parent.Parent.NavigationActions)
local validate = require(script.Parent.Parent.utils.validate)
return function(route)
local result = {}
-- Go back FROM screen identified by 'key', or the default for current route.
function result.goBack(key)
if key == nil and route.key then
validate(type(route.key) == "string", ".goBack(): key should be a string")
key = route.key
end
return NavigationActions.back({ key = key })
end
-- Navigate to a different screen, either by route name+params+action, or
-- by passing a raw navigation table.
function result.navigate(navigateTo, params, action)
if type(navigateTo) == "string" then
return NavigationActions.navigate({
routeName = navigateTo,
params = params,
action = action,
})
else
validate(type(navigateTo) == "table", ".navigate(): navigateTo must be a string or table")
validate(params == nil, ".navigate(): params can only be provided with a string navigateTo value")
validate(action == nil, ".navigate(): child action can only be provided with a string navigateTo value")
return NavigationActions.navigate(navigateTo)
end
end
-- Change navigation params for current route
function result.setParams(params)
validate(type(route.key) == "string", ".setParams(): cannot be called by the root navigator")
return NavigationActions.setParams({ params = params, key = route.key })
end
return result
end
@@ -0,0 +1,60 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/62da341b672a83786b9c3a80c8a38f929964d7cc/packages/core/src/routers/SwitchRouter.js
local root = script.Parent.Parent
local Packages = root.Parent
local Cryo = require(Packages.Cryo)
local validate = require(root.utils.validate)
local isValidScreenComponent = require(root.utils.isValidScreenComponent)
-- Extract a single screen Roact component/navigator from
-- a navigator's config.
return function(routeConfigs, routeName)
validate(type(routeConfigs) == "table", "routeConfigs must be a table")
validate(type(routeName) == "string", "routeName must be a string")
local routeConfig = routeConfigs[routeName]
if routeConfig == nil then
local possibleRoutes = Cryo.List.map(Cryo.Dictionary.keys(routeConfigs), function(name)
return ("'%s'"):format(name)
end)
local message = ("There is no route defined for key %s.\nMust be one of: %s"):format(
routeName,
table.concat(possibleRoutes, ",")
)
error(message, 2)
end
local routeConfigType = type(routeConfig)
if routeConfigType == "table" then
if routeConfig.screen ~= nil then
validate(
isValidScreenComponent(routeConfig.screen),
"screen for key '%s' must be a valid Roact component.",
routeName
)
return routeConfig.screen
elseif type(routeConfig.getScreen) == "function" then
local screen = routeConfig.getScreen()
validate(
isValidScreenComponent(screen),
"The getScreen defined for route '%s' didn't return a valid " ..
"screen or navigator.\n\n" ..
"Please pass it like this:\n" ..
"%s = {\n getScreen: function() return MyScreen end\n}",
routeName,
routeName
)
return screen
end
end
validate(
isValidScreenComponent(routeConfig),
"Value for key '%s' must be a route config table or a valid Roact component.",
routeName
)
return routeConfig
end
@@ -0,0 +1,54 @@
local validate = require(script.Parent.Parent.utils.validate)
local isValidScreenComponent = require(script.Parent.Parent.utils.isValidScreenComponent)
--[[
This utility checks to make sure that configs passed to a
router are in the correct format.
Example:
routeConfigs = {
{ routeNameEx1 = Roact.Function/Stateful_Component },
{ routeNameEx2 = { screen = Roact.Function/Stateful_Component } },
{
routeNameEx3 = {
getScreen = function() return Roact.Function/Stateful_Component end
}
}
{ routeNameEx4 = AnotherRoactNavigator } -- this is a Stateful Component
}
]]
return function(routeConfigs)
validate(type(routeConfigs) == "table", "routeConfigs must be an array table")
for index, route in pairs(routeConfigs) do
validate(
type(index) == "number",
("routeConfigs must be an array table (found non-number key %q of type %q)"):format(
index,
type(index)
)
)
local routeName, routeConfig = next(route)
validate(
next(route, routeName) == nil,
("only one route must be defined in each entry (found multiple at index %d)"):format(
index
)
)
local configIsTable = type(routeConfig) == "table" or false
local screenConfig = configIsTable and routeConfig or {} -- easy index .screen/.getScreen
local screenComponent = configIsTable and routeConfig.screen or routeConfig
validate(isValidScreenComponent(screenComponent) or
(type(screenConfig.getScreen) == "function" and isValidScreenComponent(screenConfig.getScreen())),
"The component for route '%s' must be a Roact Function/Stateful component or table with 'getScreen'." ..
"getScreen function must return Roact Function/Stateful component.",
routeName)
validate(screenConfig.screen == nil or screenConfig.getScreen == nil,
"Route '%s' should provide 'screen' or 'getScreen', but not both.", routeName)
end
validate(#routeConfigs > 0, "Please specify at least one route when configuring a navigator.")
return routeConfigs
end
@@ -0,0 +1,76 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/6390aacd07fd647d925dfec842a766c8aad5272f/packages/core/src/routers/validateRouteConfigMap.js
local root = script.Parent.Parent
local validate = require(root.utils.validate)
local isValidScreenComponent = require(root.utils.isValidScreenComponent)
local function getScreenComponent(routeConfig)
if not routeConfig then
return nil
end
if type(routeConfig) == "table" and routeConfig.screen then
return routeConfig.screen
end
return routeConfig
end
--[[
This utility checks to make sure that configs passed to a
router are in the correct format.
Example:
routeConfigs = {
routeNameEx1 = Roact.Function/Stateful_Component,
routeNameEx2 = {
screen = Roact.Function/Stateful_Component,
},
routeNameEx3 = {
getScreen = function()
return Roact.Function/Stateful_Component
end
}
routeNameEx4 = AnotherRoactNavigator -- this is a Stateful Component
}
]]
return function(routeConfigs)
validate(type(routeConfigs) == "table", "routeConfigs must be a table")
validate(next(routeConfigs) ~= nil, "Please specify at least one route when configuring a navigator.")
for routeName, routeConfig in pairs(routeConfigs) do
local screenComponent = getScreenComponent(routeConfig)
local tableRouteConfig = type(routeConfig) == "table"
validate(
isValidScreenComponent(screenComponent) or
(tableRouteConfig and type(routeConfig.getScreen) == "function"),
"The component for route '%s' must be a Roact component or table with 'getScreen'." ..
[[ For example:
local MyScreen = require(script.Parent.MyScreen)
...
%s = MyScreen,
}
You can also use a navigator:
local MyNavigator = require(script.Parent.MyNavigator)
...
%s = MyNavigator,
}]],
routeName,
routeName,
routeName
)
if tableRouteConfig then
validate(
routeConfig.screen == nil or routeConfig.getScreen == nil,
"Route '%s' should declare a screen or a getScreen, not both.",
routeName
)
end
end
return routeConfigs
end
@@ -0,0 +1,10 @@
local validate = require(script.Parent.Parent.utils.validate)
return function(screenOptions, route)
validate(type(screenOptions) == "table", "screenOptions must be a table")
validate(type(route) == "table", "route must be a table")
validate(type(route.routeName) == "string", "route.routeName must be a string")
validate(type(screenOptions.title) ~= "function",
"title cannot be defined as a function in navigation options for screen '%s'",
route.routeName)
end
@@ -0,0 +1,21 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/72e8160537954af40f1b070aa91ef45fc02bba69/packages/core/src/routers/KeyGenerator.ts
local uniqueBaseId = "id-" .. tostring(math.random(100000, 1000000))
local uuidCount = 0
local KeyGenerator = {}
-- NOTE: FOR TESTING ONLY.
-- Normalize keys so that tests can be consistent.
function KeyGenerator._TESTING_ONLY_normalize_keys()
uniqueBaseId = "id-"
uuidCount = 0
end
-- Get a string key that is unique for this session.
function KeyGenerator.generateKey()
local key = uniqueBaseId .. tostring(uuidCount)
uuidCount = uuidCount + 1
return key
end
return KeyGenerator
@@ -0,0 +1,36 @@
local validate = require(script.Parent.validate)
local PageNavigationEvent = {}
PageNavigationEvent.__index = PageNavigationEvent
function PageNavigationEvent.new(pageName, event)
validate(typeof(pageName) == "string", "pageName should be string")
validate(typeof(event) == "userdata", "event should be RoactNavigation.Event")
local self = {
event = event,
pageName = pageName,
}
setmetatable(self, PageNavigationEvent)
return self
end
function PageNavigationEvent.isPageNavigationEvent(instance)
return getmetatable(instance).__index == PageNavigationEvent
end
PageNavigationEvent.__tostring = function (pageNavigationEvent)
return string.format( "%-15s - %s", tostring(pageNavigationEvent.event), pageNavigationEvent.pageName)
end
function PageNavigationEvent:equalTo(anotherPageNavigationEvent)
validate(PageNavigationEvent.isPageNavigationEvent(anotherPageNavigationEvent), "should be PageNavigationEvent")
return self.pageName == anotherPageNavigationEvent.pageName and self.event == anotherPageNavigationEvent.event
end
return PageNavigationEvent
@@ -0,0 +1,320 @@
--[[
Provides functions for comparing and printing lua tables.
]]
local TableUtilities = {}
local defaultIgnore = {}
local function makeKeyString(key)
if type(key) == "string" then
return string.format("%s", key)
else
return string.format("[%s]", tostring(key))
end
end
local function makeValueString(value)
local valueType = type(value)
if valueType == "string" then
return string.format("%q", value)
elseif valueType == "function" or valueType == "table" then
return string.format("<%s>", tostring(value))
else
return string.format("%s", tostring(value))
end
end
local function printKeypair(key, value, indentStr, comment)
local keyString = makeKeyString(key)
local valueString = makeValueString(value)
local commentStr = comment and string.format(" -- %s", comment) or ""
print(string.format("%s%s = %s,%s", indentStr, keyString, valueString, commentStr))
end
--[[
Takes two tables A and B, returns if they have the same key-value pairs
Except ignored keys
]]
function TableUtilities.ShallowEqual(A, B, ignore)
if not A or not B then
return false
elseif A == B then
return true
end
if not ignore then
ignore = defaultIgnore
end
for key, value in pairs(A) do
if B[key] ~= value and not ignore[key] then
return false
end
end
for key, value in pairs(B) do
if A[key] ~= value and not ignore[key] then
return false
end
end
return true
end
local function formatDeepEqualMessage(message, level)
if level ~= 0 then
return message
end
return message
:gsub("{1}", "first")
:gsub("{2}", "second")
end
--[[
Takes two tables A and B, returns if they have the same key-value pairs recursively
]]
function TableUtilities.DeepEqual(a, b, level)
level = level or 0
if a == b then
return true
end
if typeof(a) ~= typeof(b) then
local message = ("{1} is of type %s, but {2} is of type %s"):format(
typeof(a),
typeof(b)
)
return false, formatDeepEqualMessage(message, level)
end
if typeof(a) == "table" then
local visitedKeys = {}
for key, value in pairs(a) do
visitedKeys[key] = true
local success, innerMessage = TableUtilities.DeepEqual(value, b[key], level + 1)
if not success then
local message = innerMessage
:gsub("{1}", ("{1}[%s]"):format(tostring(key)))
:gsub("{2}", ("{2}[%s]"):format(tostring(key)))
return false, formatDeepEqualMessage(message, level)
end
end
for key, value in pairs(b) do
if not visitedKeys[key] then
local success, innerMessage = TableUtilities.DeepEqual(a[key], value, level + 1)
if not success then
local message = innerMessage
:gsub("{1}", ("{1}[%s]"):format(tostring(key)))
:gsub("{2}", ("{2}[%s]"):format(tostring(key)))
return false, formatDeepEqualMessage(message, level)
end
end
end
return true
end
local message = "{1} ~= {2}"
return false, formatDeepEqualMessage(message, level)
end
--[[
Takes two tables A, B and a key, returns if two tables have the same value at key
]]
function TableUtilities.EqualKey(A, B, key)
if A and B and key and key ~= "" and A[key] and B[key] and A[key] == B[key] then
return true
end
return false
end
--[[
Takes two tables A and B, returns a new table with elements of A
which are either not keys in B or have a different value in B
]]
function TableUtilities.TableDifference(A, B)
local new = {}
for key, value in pairs(A) do
if B[key] ~= A[key] then
new[key] = value
end
end
return new
end
--[[
Takes a list and returns a table whose
keys are elements of the list and whose
values are all true
]]
local function membershipTable(list)
local result = {}
for i = 1, #list do
result[list[i]] = true
end
return result
end
--[[
Takes a table and returns a list of keys in that table
]]
local function listOfKeys(t)
local result = {}
for key,_ in pairs(t) do
table.insert(result, key)
end
return result
end
--[[
Takes two lists A and B, returns a new list of elements of A
which are not in B
]]
function TableUtilities.ListDifference(A, B)
return listOfKeys(TableUtilities.TableDifference(membershipTable(A), membershipTable(B)))
end
--[[
For debugging. Returns false if the given table has any of the following:
- a key that is neither a number or a string
- a mix of number and string keys
- number keys which are not exactly 1..#t
]]
function TableUtilities.CheckListConsistency(t)
local containsNumberKey = false
local containsStringKey = false
local numberConsistency = true
local index = 1
for x, _ in pairs(t) do
if type(x) == 'string' then
containsStringKey = true
elseif type(x) == 'number' then
if index ~= x then
numberConsistency = false
end
containsNumberKey = true
else
return false
end
if containsStringKey and containsNumberKey then
return false
end
index = index + 1
end
if containsNumberKey then
return numberConsistency
end
return true
end
--[[
For debugging, serializes the given table to a reasonable string that might even interpret as lua.
]]
function TableUtilities.RecursiveToString(t, indent)
indent = indent or ''
if type(t) == 'table' then
local result = ""
if not TableUtilities.CheckListConsistency(t) then
result = result .. "-- WARNING: this table fails the list consistency test\n"
end
result = result .. "{\n"
for k,v in pairs(t) do
if type(k) == 'string' then
result = result
.. " "
.. indent
.. tostring(k)
.. " = "
.. TableUtilities.RecursiveToString(v, " "..indent)
..";\n"
end
if type(k) == 'number' then
result = result .. " " .. indent .. TableUtilities.RecursiveToString(v, " "..indent)..",\n"
end
end
result = result .. indent .. "}"
return result
else
return tostring(t)
end
end
--[[
For debugging. Prints the table on multiple lines to overcome log-line length
limitations which are otherwise necessary for performance. Use sparingly.
]]
function TableUtilities.Print(t, indent)
indent = indent or ' '
if type(t) ~= "table" then
error("TableUtilities.Print must be passed a table", 2)
end
-- For cycle detection
local printedTables = {}
local function recurse(subTable, tableKey, level)
-- Prevent cycles by keeping track of what tables we have printed
printedTables[subTable] = true
local indentStr = string.rep(indent, level)
local valueIndentStr = string.rep(indent, level + 1)
if tableKey then
print(string.format("%s%s = %s {", indentStr, makeKeyString(tableKey), makeValueString(subTable)))
else
print(string.format("%s%s {", indentStr, makeValueString(subTable)))
end
for key, value in pairs(subTable) do
if type(value) == "table" then
if printedTables[value] then
printKeypair(key, value, valueIndentStr, "Possible cycle")
else
recurse(value, key, level + 1)
end
else
printKeypair(key, value, valueIndentStr)
end
end
print(string.format("%s}%s", indentStr, (level > 0 and "," or "")))
end
recurse(t, nil, 0)
end
--[[
Takes a table and returns the field count
]]
function TableUtilities.FieldCount(t)
local fieldCount = 0
for _ in pairs(t) do
fieldCount = fieldCount + 1
end
return fieldCount
end
return TableUtilities
@@ -0,0 +1,118 @@
local root = script.Parent.Parent
local Packages = root.Parent
local Cryo = require(Packages.Cryo)
local Roact = require(Packages.Roact)
local NavigationEvents = require(root.views.NavigationEvents)
local Events = require(root.Events)
local validate = require(script.Parent.validate)
local PageNavigationEvent = require(script.Parent.PageNavigationEvent)
local TrackNavigationEvents = {}
TrackNavigationEvents.__index = TrackNavigationEvents
function TrackNavigationEvents.new()
local self = {
navigationEvents = {},
}
setmetatable(self, TrackNavigationEvents)
return self
end
function TrackNavigationEvents:getNavigationEvents()
return self.navigationEvents
end
function TrackNavigationEvents:printNavigationEvents()
print("Total Events: ", #self.navigationEvents)
for _, navigationEvent in ipairs(self.navigationEvents) do
print(navigationEvent)
end
end
function TrackNavigationEvents:waitForNumberEventsMaxWaitTime(numberOfEvents, maxWaitTimeInSeconds)
local secondsWaitedFor = 0
local waitDurationPerIteration = 0.33
while #self.navigationEvents < numberOfEvents
and secondsWaitedFor <= maxWaitTimeInSeconds
do
wait(waitDurationPerIteration)
-- print("waiting for number of events to reach:", numberOfEvents, "waited for:", secondsWaitedFor)
secondsWaitedFor = secondsWaitedFor + waitDurationPerIteration
end
end
function TrackNavigationEvents:resetNavigationEvents()
self.navigationEvents = {}
end
local propNameToEvent = {
onWillFocus = Events.WillFocus,
onDidFocus = Events.DidFocus,
onWillBlur = Events.WillBlur,
onDidBlur = Events.DidBlur,
}
function TrackNavigationEvents:createNavigationAdapter(pageName)
local props = {}
for propName, event in pairs(propNameToEvent) do
props[propName] = function()
PageNavigationEvent.new(pageName, event)
table.insert(self.navigationEvents, PageNavigationEvent.new(pageName, event))
end
end
return Roact.createElement(NavigationEvents, props)
end
function TrackNavigationEvents:equalTo(pageNavigationEventList)
validate(typeof(pageNavigationEventList) == "table", "should be a list")
local numberOfEvents = #self.navigationEvents
if numberOfEvents ~= #pageNavigationEventList then
return false, "different amount of events"
end
for i=1, numberOfEvents do
if not self.navigationEvents[i]:equalTo(pageNavigationEventList[i]) then
return false, ("events at position %d do not match"):format(i)
end
end
return true
end
function TrackNavigationEvents:expect(pageNavigationEventList)
local result, message = self:equalTo(pageNavigationEventList)
if not result then
local selfEvents = "{}"
local expectedEvents = "{}"
if #self.navigationEvents > 0 then
selfEvents = ("{\n %s,\n}"):format(
table.concat(
Cryo.List.map(self.navigationEvents, tostring),
',\n '
)
)
end
if #pageNavigationEventList > 0 then
expectedEvents = ("{\n %s,\n}"):format(
table.concat(
Cryo.List.map(pageNavigationEventList, tostring),
',\n '
)
)
end
error(("%s\nGot events: %s\n\nExpected events: %s"):format(
message,
selfEvents,
expectedEvents
))
end
end
return TrackNavigationEvents
@@ -0,0 +1,14 @@
return function()
local KeyGenerator = require(script.Parent.Parent.KeyGenerator)
it("should generate a new string key when called", function()
KeyGenerator._TESTING_ONLY_normalize_keys()
expect(KeyGenerator.generateKey()).to.equal("id-0")
expect(KeyGenerator.generateKey()).to.equal("id-1")
end)
it("should generate unique string keys without being normalized", function()
expect(KeyGenerator.generateKey()).to.never.equal(KeyGenerator.generateKey())
end)
end
@@ -0,0 +1,46 @@
local RoactNavigation = require(script.Parent.Parent.Parent)
local PageNavigationEvent = require(script.Parent.Parent.PageNavigationEvent)
return function()
local testPage = "TEST PAGE"
local willFocusEvent = RoactNavigation.Events.WillFocus
it("should validate constructor inputs", function()
expect(function () PageNavigationEvent.new(testPage, willFocusEvent) end).never.to.throw()
expect(function () PageNavigationEvent.new(testPage, 1) end).to.throw()
expect(function () PageNavigationEvent.new(testPage, "event") end).to.throw()
expect(function () PageNavigationEvent.new(testPage, nil) end).to.throw()
expect(function () PageNavigationEvent.new(testPage, {some = "junk"}) end).to.throw()
expect(function () PageNavigationEvent.new(1, willFocusEvent) end).to.throw()
expect(function () PageNavigationEvent.new(nil, willFocusEvent) end).to.throw()
expect(function () PageNavigationEvent.new({"bogus"}, willFocusEvent) end).to.throw()
end)
it("should be constructed from page name and RoactNavigation.Events", function()
for _, event in pairs(RoactNavigation.Events) do
local pageName = testPage .. tostring(event)
local testPageNavigationEvent = PageNavigationEvent.new(pageName, event)
expect(testPageNavigationEvent.pageName).to.be.equal(pageName)
expect(testPageNavigationEvent.event).to.be.equal(event)
expect(PageNavigationEvent.isPageNavigationEvent(testPageNavigationEvent)).to.be.equal(true)
end
end)
it("should implement tostring and eq", function()
for _, event in pairs(RoactNavigation.Events) do
local pageName = testPage .. tostring(event)
local testPageNavigationEvent = PageNavigationEvent.new(pageName, event)
expect(testPageNavigationEvent:equalTo(PageNavigationEvent.new(pageName, event))).to.be.equal(true)
expect(tostring(testPageNavigationEvent)).to.be.equal(string.format("%-15s - %s",tostring(event), pageName))
end
local testPageNavigationEvent = PageNavigationEvent.new(testPage, willFocusEvent)
local willFocus = PageNavigationEvent.new(testPage, willFocusEvent)
expect(testPageNavigationEvent:equalTo(willFocus)).to.be.equal(true)
local bogusWillFocus = PageNavigationEvent.new(testPage .. "bogus", willFocusEvent)
expect(testPageNavigationEvent:equalTo(bogusWillFocus)).to.be.equal(false)
local willBlur = PageNavigationEvent.new(testPage, RoactNavigation.Events.WillBlur)
expect(testPageNavigationEvent:equalTo(willBlur)).to.be.equal(false)
end)
end
@@ -0,0 +1,115 @@
return function()
local TableUtilities = require(script.Parent.Parent.TableUtilities)
describe("DeepEqual", function()
it("should succeed", function()
expect(false).to.be.ok()
end)
it("should fail with a message when args are not equal", function()
local success, message = TableUtilities.DeepEqual(1, 2)
expect(success).to.equal(false)
expect(message:find("first ~= second")).to.be.ok()
success, message = TableUtilities.DeepEqual({
foo = 1,
}, {
foo = 2,
})
expect(success).to.equal(false)
expect(message:find("first%[foo%] ~= second%[foo%]")).to.be.ok()
end)
it("should compare non-table values using standard '==' equality", function()
expect(TableUtilities.DeepEqual(1, 1)).to.equal(true)
expect(TableUtilities.DeepEqual("hello", "hello")).to.equal(true)
expect(TableUtilities.DeepEqual(nil, nil)).to.equal(true)
local someFunction = function() end
local theSameFunction = someFunction
expect(TableUtilities.DeepEqual(someFunction, theSameFunction)).to.equal(true)
local A = {
foo = someFunction
}
local B = {
foo = theSameFunction
}
expect(TableUtilities.DeepEqual(A, B)).to.equal(true)
end)
it("should fail when types differ", function()
local success, message = TableUtilities.DeepEqual(1, "1")
expect(success).to.equal(false)
expect(message:find("first is of type number, but second is of type string")).to.be.ok()
end)
it("should compare (and report about) nested tables", function()
local A = {
foo = "bar",
nested = {
foo = 1,
bar = 2,
}
}
local B = {
foo = "bar",
nested = {
foo = 1,
bar = 2,
}
}
expect(TableUtilities.DeepEqual(A, B)).to.equal(true)
local C = {
foo = "bar",
nested = {
foo = 1,
bar = 3,
}
}
local success, message = TableUtilities.DeepEqual(A, C)
expect(success).to.equal(false)
expect(message:find("first%[nested%]%[bar%] ~= second%[nested%]%[bar%]")).to.be.ok()
end)
it("should be commutative", function()
local equalArgsA = {
foo = "bar",
hello = "world",
}
local equalArgsB = {
foo = "bar",
hello = "world",
}
expect(TableUtilities.DeepEqual(equalArgsA, equalArgsB)).to.equal(true)
expect(TableUtilities.DeepEqual(equalArgsB, equalArgsA)).to.equal(true)
local nonEqualArgs = {
foo = "bar",
}
local successA = TableUtilities.DeepEqual(equalArgsA, nonEqualArgs)
local successB = TableUtilities.DeepEqual(nonEqualArgs, equalArgsA)
expect(successA).to.equal(false)
expect(successB).to.equal(false)
end)
it("should give the appropriate message if the second table has extra fields", function()
local success, message = TableUtilities.DeepEqual({}, { foo = 1 })
expect(success).to.equal(false)
expect(message).to.equal("first[foo] is of type nil, but second[foo] is of type number")
end)
end)
end
@@ -0,0 +1,36 @@
local RoactNavigation = require(script.Parent.Parent.Parent)
local TrackNavigationEvents = require(script.Parent.Parent.TrackNavigationEvents)
local PageNavigationEvent = require(script.Parent.Parent.PageNavigationEvent)
return function()
local testPage = "TEST PAGE"
local testPageWillFocus = PageNavigationEvent.new(testPage, RoactNavigation.Events.WillFocus)
local testPageWillBlur = PageNavigationEvent.new(testPage, RoactNavigation.Events.WillBlur)
local trackNavigationEvents = TrackNavigationEvents.new()
it("should implement equalTo function", function()
expect(trackNavigationEvents:equalTo({})).to.be.equal(true)
local navigationEvents = trackNavigationEvents:getNavigationEvents()
table.insert(navigationEvents, testPageWillFocus)
expect(trackNavigationEvents:equalTo({testPageWillFocus})).to.be.equal(true)
expect(trackNavigationEvents:equalTo({})).to.be.equal(false)
table.insert(navigationEvents, testPageWillBlur)
expect(trackNavigationEvents:equalTo({testPageWillFocus, testPageWillBlur})).to.be.equal(true)
table.insert(navigationEvents, testPageWillFocus)
expect(trackNavigationEvents:equalTo({testPageWillFocus, testPageWillBlur})).to.be.equal(false)
expect(trackNavigationEvents:equalTo({testPageWillFocus, testPageWillBlur, testPageWillFocus})).to.be.equal(true)
end)
it("should be empty after reset", function()
trackNavigationEvents:resetNavigationEvents()
local navigationEvents = trackNavigationEvents:getNavigationEvents()
expect(typeof(navigationEvents)).to.be.equal('table')
expect(#navigationEvents).to.be.equal(0)
expect(trackNavigationEvents:equalTo({})).to.be.equal(true)
end)
end
@@ -0,0 +1,145 @@
return function()
local createSpy = require(script.Parent.Parent.createSpy)
describe("createSpy", function()
it("should create spies", function()
local spy = createSpy(function() end)
expect(spy).to.be.ok()
end)
it("should throw if spies are indexed by an invalid key", function()
local spy = createSpy(function() end)
expect(function()
return spy.test
end).to.throw()
end)
end)
describe("value", function()
it("should increment callCount when called", function()
local spy = createSpy(function() end)
spy.value()
expect(spy.callCount).to.equal(1)
end)
it("should store all values passed", function()
local spy = createSpy(function() end)
spy.value(1, true, "3")
expect(spy.valuesLength).to.equal(3)
expect(spy.values[1]).to.equal(1)
expect(spy.values[2]).to.equal(true)
expect(spy.values[3]).to.equal("3")
end)
it("should return the value of the inner function", function()
local spy = createSpy(function()
return true
end)
expect(spy.value()).to.equal(true)
end)
end)
describe("assertCalledWith", function()
it("should throw if the number of values differs", function()
local spy = createSpy(function() end)
spy.value(1, 2)
expect(function()
spy:assertCalledWith(1)
end).to.throw()
end)
it("should throw if any value differs", function()
local spy = createSpy(function() end)
spy.value(1, 2)
expect(function()
spy:assertCalledWith(1, 3)
end).to.throw()
expect(function()
spy:assertCalledWith(2, 3)
end).to.throw()
end)
end)
describe("captureValues", function()
it("should throw if the number of values differs", function()
local spy = createSpy(function() end)
spy.value(1, 2)
expect(function()
spy:captureValues("a")
end).to.throw()
end)
it("should capture all values in a table", function()
local spy = createSpy(function() end)
spy.value(1, 2)
local captured = spy:captureValues("a", "b")
expect(captured.a).to.equal(1)
expect(captured.b).to.equal(2)
end)
end)
describe("calls", function()
it("should keep all the arguments given to each call", function()
local spy = createSpy()
local firstCall = {}
spy.value(firstCall)
local secondCall = {}
spy.value(secondCall)
expect(spy.calls[1][1]).to.equal(firstCall)
expect(spy.calls[2][1]).to.equal(secondCall)
end)
end)
describe("mockClear", function()
it("clears the spy state", function()
local spy = createSpy()
spy.value(1)
expect(spy.callCount).to.equal(1)
spy:mockClear()
expect(spy.callCount).to.equal(0)
expect(#spy.calls).to.equal(0)
expect(#spy.values).to.equal(0)
expect(spy.valuesLength).to.equal(0)
end)
end)
describe("call", function()
it("should increment callCount when called", function()
local spy = createSpy(function() end)
spy()
expect(spy.callCount).to.equal(1)
end)
it("should store all values passed", function()
local spy = createSpy(function() end)
spy(1, true, "3")
expect(spy.valuesLength).to.equal(3)
expect(spy.values[1]).to.equal(1)
expect(spy.values[2]).to.equal(true)
expect(spy.values[3]).to.equal("3")
end)
it("should return the value of the inner function", function()
local spy = createSpy(function()
return true
end)
expect(spy()).to.equal(true)
end)
end)
end
@@ -0,0 +1,47 @@
return function()
local getActiveChildNavigationOptions = require(script.Parent.Parent.getActiveChildNavigationOptions)
it("should return a function", function()
expect(type(getActiveChildNavigationOptions)).to.equal("function")
end)
it("should ask router for current screen options and return them", function()
local testInputScreenOpts = {}
local testScreenOpts = {}
local navigation = {
state = {
routes = {
{ key = "123" }
},
index = 1,
},
router = {}, -- stub
}
function navigation.getChildNavigation(key)
if key == "123" then
return navigation
else
return nil
end
end
local testOutputScreenOpts = nil
function navigation.router.getScreenOptions(activeNav, screenProps)
testOutputScreenOpts = screenProps
if activeNav == navigation then
return testScreenOpts
else
return nil
end
end
expect(getActiveChildNavigationOptions(navigation, testInputScreenOpts))
.to.equal(testScreenOpts)
expect(testOutputScreenOpts).to.equal(testInputScreenOpts)
end)
end
@@ -0,0 +1,25 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local isValidScreenComponent = require(script.Parent.Parent.isValidScreenComponent)
local TestComponent = Roact.Component:extend("TestFoo")
it("should return true for valid element types", function()
-- Function Component is valid
expect(isValidScreenComponent(function() end)).to.equal(true)
-- Stateful Component is valid
expect(isValidScreenComponent(TestComponent)).to.equal(true)
expect(isValidScreenComponent(
{ render = function() return TestComponent end })).to.equal(true)
expect(isValidScreenComponent( -- we do not test if render function returns valid component
{ render = function() end })).to.equal(true)
end)
it("should return false for invalid element types", function()
expect(isValidScreenComponent("foo")).to.equal(false)
expect(isValidScreenComponent(Roact.createElement("Frame"))).to.equal(false)
expect(isValidScreenComponent(5)).to.equal(false)
expect(isValidScreenComponent(Roact.Portal)).to.equal(false)
expect(isValidScreenComponent({ render = "bad" })).to.equal(false)
expect(isValidScreenComponent(
{ notRender = function() return "foo" end })).to.equal(false)
end)
end
@@ -0,0 +1,21 @@
return function()
local lerp = require(script.Parent.Parent.lerp)
it("should return bottom of range for bottom input", function()
expect(lerp(0, 1, 0)).to.equal(0)
expect(lerp(1, 0, 0)).to.equal(1)
expect(lerp(-1, 0, 0)).to.equal(-1)
end)
it("should return middle of range for middle input", function()
expect(lerp(0, 1, 0.5)).to.equal(0.5)
expect(lerp(1, 0, 0.5)).to.equal(0.5)
expect(lerp(-1, 0, 0.5)).to.equal(-0.5)
end)
it("should return top of range for top input", function()
expect(lerp(0, 1, 1)).to.equal(1)
expect(lerp(1, 0, 1)).to.equal(0)
expect(lerp(-1, 0, 1)).to.equal(0)
end)
end
@@ -0,0 +1,93 @@
-- Taken from Roact
local expectDeepEqual = require(script.Parent.expectDeepEqual)
local function createSpy(inner)
local self = {
callCount = 0,
calls = {},
values = {},
valuesLength = 0,
}
self.value = function(...)
self.callCount = self.callCount + 1
self.values = {...}
self.valuesLength = select("#", ...)
table.insert(self.calls, self.values)
if inner ~= nil then
return inner(...)
end
return
end
self.assertCalledWith = function(_, ...)
local len = select("#", ...)
if self.valuesLength ~= len then
error(("Expected %d arguments, but was called with %d arguments"):format(
self.valuesLength,
len
), 2)
end
for i = 1, len do
local expected = select(i, ...)
assert(self.values[i] == expected, "value differs")
end
end
self.assertCalledWithDeepEqual = function(_, ...)
local len = select("#", ...)
if self.valuesLength ~= len then
error(("Expected %d arguments, but was called with %d arguments"):format(
self.valuesLength,
len
), 2)
end
for i = 1, len do
local expected = select(i, ...)
expectDeepEqual(self.values[i], expected)
end
end
self.captureValues = function(_, ...)
local len = select("#", ...)
local result = {}
assert(self.valuesLength == len, "length of expected values differs from stored values")
for i = 1, len do
local key = select(i, ...)
result[key] = self.values[i]
end
return result
end
self.mockClear = function()
self.callCount = 0
self.calls = {}
self.values = {}
self.valuesLength = 0
end
setmetatable(self, {
__index = function(_, key)
error(("%q is not a valid member of spy"):format(key))
end,
__call = function(_, ...)
return self.value(...)
end
})
return self
end
return createSpy
@@ -0,0 +1,10 @@
local TableUtilities = require(script.Parent.TableUtilities)
return function(a, b)
local success, innerMessage = TableUtilities.DeepEqual(a, b)
if not success then
local message = ("Values were not deep-equal.\n%s"):format(innerMessage)
error(message, 2)
end
end
@@ -0,0 +1,47 @@
return function()
local expectDeepEqual = require(script.Parent.expectDeepEqual)
it("should fail with a message when args are not equal", function()
expect(function()
expectDeepEqual(1, 2)
end).to.throw("Values were not deep-equal.\nfirst ~= second")
expect(function()
expectDeepEqual({
foo = 1,
}, {
foo = 2,
})
end).to.throw("Values were not deep-equal.\nfirst[foo] ~= second[foo]")
end)
it("should succeed when comparing non-table equal values", function()
expect(function()
expectDeepEqual(1, 1)
end).never.to.throw()
expect(function()
expectDeepEqual("hello", "hello")
end).never.to.throw()
expect(function()
expectDeepEqual(nil, nil)
end).never.to.throw()
local someFunction = function() end
local theSameFunction = someFunction
expect(function()
expectDeepEqual(someFunction, theSameFunction)
end).never.to.throw()
end)
it("should succeed when comparing different table identities with same structure", function()
expect(function()
expectDeepEqual({
foo = "bar",
}, {
foo = "bar",
})
end).never.to.throw()
end)
end
@@ -0,0 +1,14 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/6390aacd07fd647d925dfec842a766c8aad5272f/packages/core/src/utils/getActiveChildNavigationOptions.js
-- deviation: no theme parameter because no support for theme
return function(navigation, screenProps)
local state = navigation.state
local router = navigation.router
local getChildNavigation = navigation.getChildNavigation
local activeRoute = state.routes[state.index]
local activeNavigation = getChildNavigation(activeRoute.key)
-- deviation: no support for theme
return router.getScreenOptions(activeNavigation, screenProps)
end
@@ -0,0 +1,44 @@
local Cryo = require(script.Parent.Parent.Parent.Cryo)
return function(props)
local scene = props.scene
local scenes = props.scenes
local index = scene.index
local lastSceneIndexInScenes = #scenes
local isBack = not scenes[lastSceneIndexInScenes].isActive
if isBack then
local currentSceneIndexInScenes = Cryo.List.find(scenes, scene)
local targetSceneIndexInScenes = nil
for i, iScene in ipairs(scenes) do
if iScene.isActive then
targetSceneIndexInScenes = i
break
end
end
local targetSceneIndex = scenes[targetSceneIndexInScenes].index
local lastSceneIndex = scenes[lastSceneIndexInScenes].index
if index ~= targetSceneIndex and currentSceneIndexInScenes == lastSceneIndexInScenes then
return {
first = math.min(targetSceneIndex, index - 1),
last = index + 1,
}
elseif index == targetSceneIndex and currentSceneIndexInScenes == targetSceneIndexInScenes then
return {
first = index - 1,
last = math.max(lastSceneIndex, index + 1)
}
elseif index == targetSceneIndex or currentSceneIndexInScenes > targetSceneIndexInScenes then
return nil
end
end
return {
first = index - 1,
last = index + 1
}
end
@@ -0,0 +1,10 @@
-- We have to do this using type because ElementKind is not exported by Roact
return function (screenComponent)
local componentType = type(screenComponent)
local valid = componentType == "function" or -- Function Component
(componentType == "table" and type(screenComponent.render) == "function") -- Stateful Component
return valid
end
@@ -0,0 +1,6 @@
-- Helper interpolates t with range [0,1] into the range [a,b].
local function lerp(a, b, t)
return a * (1 - t) + b * t
end
return lerp
@@ -0,0 +1,19 @@
--[[
Validate() provides a mechanism to validate input arguments and
internal state for your components. You can call it like this:
function myFunc(arg1, arg2)
validate(arg1 ~= arg2, "arg1 (%s) and arg2 (%s) must be different!",
tostring(arg1), tostring(arg2))
return doSomething(arg1, arg2)
end
The error will be surfaced at the *call site* of your function.
]]
return function(result, ...)
if not result then
error(string.format(...), 3)
end
return result
end
@@ -0,0 +1,7 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/20e2625f351f90fadadbf98890270e43e744225b/packages/core/src/views/NavigationContext.ts
local Roact = require(script.Parent.Parent.Parent.Roact)
local NavigationContext = Roact.createContext(nil)
return NavigationContext
@@ -0,0 +1,104 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/6390aacd07fd647d925dfec842a766c8aad5272f/packages/core/src/views/NavigationEvents.js
local root = script.Parent.Parent
local Packages = root.Parent
local Roact = require(Packages.Roact)
local withNavigation = require(script.Parent.withNavigation)
local Events = require(root.Events)
--[[
NavigationEvents providers a wrapper component that allows you to subscribe
to the navigation lifecycle events without having to explicitly manage your own
listener subscriptions.
Usage:
function MyComponent:init()
self.willFocus = function()
-- Do tasks that need to happen right before the component will appear on screen.
end
self.didFocus = function()
-- Do tasks that need to happen right after the component appears on screen.
end
end
function MyComponent:render()
-- Note that you must capture the self reference lexically, if you need it.
return Roact.createElement(RoactNavigation.NavigationEvents, {
onWillFocus = self.willFocus,
onDidFocus = self.didFocus,
onWillBlur = self.willBlur,
onDidBlur = self.didBlur,
})
end
Remember that focus and blur events may be called more than once in the lifetime of a
component. If you navigate away from a component and then come back later, it will receive
willBlur/didBlur and then willFocus/didFocus events.
Also remember that your event handlers must capture any self reference lexically, if necessary.
]]
local EventNameToPropName = {
[Events.WillFocus] = "onWillFocus",
[Events.DidFocus] = "onDidFocus",
[Events.WillBlur] = "onWillBlur",
[Events.DidBlur] = "onDidBlur",
}
local NavigationEvents = Roact.Component:extend("NavigationEvents")
function NavigationEvents:didMount()
-- We register all navigation listeners on mount to ensure listener stability across re-render
-- A former implementation was replacing (removing/adding) listeners on all update (if prop provided)
-- but there were issues (see https://github.com/react-navigation/react-navigation/issues/5058)
self:subscribeAll()
end
function NavigationEvents:didUpdate(prevProps)
if self.props.navigation ~= prevProps.navigation then
-- This component might get reused for different state, so we need to hook back up to events
self:removeAll()
self:subscribeAll()
end
end
function NavigationEvents:willUnmount()
self:removeAll()
end
function NavigationEvents:getPropListener(eventName)
return self.props[EventNameToPropName[eventName]]
end
function NavigationEvents:subscribeAll()
local navigation = self.props.navigation
self.subscriptions = {}
for symbol in pairs(EventNameToPropName) do
self.subscriptions[symbol] = navigation.addListener(symbol, function(...)
-- Retrieve callback from props each time, in case props change.
local callback = self:getPropListener(symbol)
if callback then
callback(...)
end
end)
end
end
function NavigationEvents:removeAll()
for symbol in pairs(EventNameToPropName) do
local sub = self.subscriptions[symbol]
if sub then
sub.remove()
self.subscriptions[symbol] = nil
end
end
end
function NavigationEvents:render()
return nil
end
return withNavigation(NavigationEvents)
@@ -0,0 +1,347 @@
-- upstream https://github.com/react-navigation/react-navigation/blob/9b55493e7662f4d54c21f75e53eb3911675f61bc/packages/core/src/views/NavigationFocusEvents.js
local root = script.Parent.Parent
local Packages = root.Parent
local Cryo = require(Packages.Cryo)
local Roact = require(Packages.Roact)
local Events = require(root.Events)
local NavigationEventManager = Roact.Component:extend("NavigationEventManager")
function NavigationEventManager:didMount()
local navigation = self.props.navigation
self._actionSubscription = navigation.addListener(
Events.Action,
function(...)
return self:_handleAction(...)
end
)
self._willFocusSubscription = navigation.addListener(
Events.WillFocus,
function(...)
return self:_handleWillFocus(...)
end
)
self._willBlurSubscription = navigation.addListener(
Events.WillBlur,
function(...)
return self:_handleWillBlur(...)
end
)
self._didFocusSubscription = navigation.addListener(
Events.DidFocus,
function(...)
return self:_handleDidFocus(...)
end
)
self._didBlurSubscription = navigation.addListener(
Events.DidBlur,
function(...)
return self:_handleDidBlur(...)
end
)
self._refocusSubscription = navigation.addListener(
Events.Refocus,
function(...)
return self:_handleRefocus(...)
end
)
end
function NavigationEventManager:willUnmount()
if self._actionSubscription then
self._actionSubscription.remove()
end
if self._willFocusSubscription then
self._willFocusSubscription.remove()
end
if self._willBlurSubscription then
self._willBlurSubscription.remove()
end
if self._didFocusSubscription then
self._didFocusSubscription.remove()
end
if self._didBlurSubscription then
self._didBlurSubscription.remove()
end
if self._refocusSubscription then
self._refocusSubscription.remove()
end
end
function NavigationEventManager:_handleAction(actionPayload)
local state = actionPayload.state
local lastState = actionPayload.lastState
local action = actionPayload.action
local type = actionPayload.type
local context = actionPayload.context
local navigation = self.props.navigation
local onEvent = self.props.onEvent
-- We should only emit events when the navigator is focused
-- When navigator is not focused, screens inside shouldn"t receive focused status either
if not navigation.isFocused() then
return
end
local previous
if lastState and lastState.routes then
previous = lastState.routes[lastState.index]
end
local current = state.routes[state.index]
local payload = {
context = ("%s:%s_%s"):format(current.key, tostring(action.type), context or "Root"),
state = current,
lastState = previous,
action = action,
type = type,
}
if (previous and previous.key) ~= current.key then
self:_emitWillFocus(current.key, payload)
if previous and previous.key then
self:_emitWillBlur(previous.key, payload)
end
end
if lastState and lastState.isTransitioning ~= state.isTransitioning and
state.isTransitioning == false
then
if self._lastWillBlurKey then
self:_emitDidBlur(self._lastWillBlurKey, payload)
end
if self._lastWillFocusKey then
self:_emitDidFocus(self._lastWillFocusKey, payload)
end
end
onEvent(current.key, Events.Action, payload)
end
function NavigationEventManager:_handleWillFocus(args)
local lastState = args.lastState
local action = args.action
local context = args.context
local type = args.type
local navigation = self.props.navigation
local route = navigation.state.routes[navigation.state.index]
local nextLastState = nil
if lastState and lastState.routes then
local nextLastStateIndex = Cryo.List.findWhere(lastState and lastState.routes or {}, function(r)
return r.key == route.key
end)
if nextLastStateIndex then
nextLastState = lastState.routes[nextLastStateIndex]
end
end
self:_emitWillFocus(route.key, {
context = ("%s:%s_%s"):format(route.key, tostring(action.type), context or "Root"),
state = route,
lastState = nextLastState,
action = action,
type = type,
})
end
function NavigationEventManager:_handleWillBlur(args)
local lastState = args.lastState
local action = args.action
local context = args.context
local type = args.type
local navigation = self.props.navigation
local route = navigation.state.routes[navigation.state.index]
local nextLastState = nil
if lastState and lastState.routes then
local nextLastStateIndex = Cryo.List.findWhere(lastState and lastState.routes or {}, function(r)
return r.key == route.key
end)
if nextLastStateIndex then
nextLastState = lastState.routes[nextLastStateIndex]
end
end
self:_emitWillBlur(route.key, {
context = ("%s:%s_%s"):format(route.key, tostring(action.type), context or "Root"),
state = route,
lastState = nextLastState,
action = action,
type = type,
});
end
function NavigationEventManager:_handleDidFocus(args)
local lastState = args.lastState
local action = args.action
local context = args.context
local type = args.type
local navigation = self.props.navigation
if self._lastWillFocusKey then
local routeIndex = Cryo.List.findWhere(navigation.state.routes, function(r)
return r.key == self._lastWillFocusKey
end)
if routeIndex then
local route = navigation.state.routes[routeIndex]
local nextLastState = nil
if lastState and lastState.routes then
local nextLastStateIndex = Cryo.List.findWhere(lastState and lastState.routes or {}, function(r)
return r.key == route.key
end)
if nextLastStateIndex then
nextLastState = lastState.routes[nextLastStateIndex]
end
end
self:_emitDidFocus(route.key, {
context = ("%s:%s_%s"):format(route.key, tostring(action.type), context or "Root"),
state = route,
lastState = nextLastState,
action = action,
type = type,
});
end
end
end
function NavigationEventManager:_handleDidBlur(args)
local lastState = args.lastState
local action = args.action
local context = args.context
local type = args.type
local navigation = self.props.navigation
if self._lastWillBlurKey then
local routeIndex = Cryo.List.findWhere(navigation.state.routes, function(r)
return r.key == self._lastWillBlurKey
end)
if routeIndex then
local route = navigation.state.routes[routeIndex]
local nextLastState = nil
if lastState and lastState.routes then
local nextLastStateIndex = Cryo.List.findWhere(lastState and lastState.routes or {}, function(r)
return r.key == route.key
end)
if nextLastStateIndex then
nextLastState = lastState.routes[nextLastStateIndex]
end
end
self:_emitDidBlur(route.key, {
context = ("%s:%s_%s"):format(route.key, tostring(action.type), context or "Root"),
state = route,
lastState = nextLastState,
action = action,
type = type,
});
end
end
end
function NavigationEventManager:_handleRefocus()
local onEvent = self.props.onEvent
local navigation = self.props.navigation
local route = navigation.state.routes[navigation.state.index]
onEvent(route.key, Events.Refocus)
end
function NavigationEventManager:_emitWillFocus(target, payload)
if self._lastWillBlurKey == target then
self._lastWillBlurKey = nil
end
if self._lastWillFocusKey == target then
return
end
self._lastDidFocusKey = nil
self._lastWillFocusKey = target
local navigation = self.props.navigation
local onEvent = self.props.onEvent
onEvent(target, Events.WillFocus, payload);
if typeof(navigation.state.isTransitioning) ~= "boolean" or
(navigation.state.isTransitioning ~= true and
not navigation._dangerouslyGetParent()) -- TODO: what should we do with dangerouslyGetParent
then
self:_emitDidFocus(target, payload)
end
end
function NavigationEventManager:_emitWillBlur(target, payload)
if self._lastWillFocusKey == target then
self._lastWillFocusKey = nil
end
if self._lastWillBlurKey == target then
return
end
self._lastDidBlurKey = nil
self._lastWillBlurKey = target
local navigation = self.props.navigation
local onEvent = self.props.onEvent
onEvent(target, Events.WillBlur, payload)
if typeof(navigation.state.isTransitioning) ~= "boolean" or
(navigation.state.isTransitioning ~= true and
not navigation._dangerouslyGetParent())
then
self:_emitDidBlur(target, payload)
end
end
function NavigationEventManager:_emitDidFocus(target, payload)
if self._lastWillFocusKey ~= target or self._lastDidFocusKey == target then
return
end
self._lastDidFocusKey = target
local onEvent = self.props.onEvent
onEvent(target, Events.DidFocus, payload)
end
function NavigationEventManager:_emitDidBlur(target, payload)
if self._lastWillBlurKey ~= target or self._lastDidBlurKey == target then
return
end
self._lastDidBlurKey = target
local onEvent = self.props.onEvent
onEvent(target, Events.DidBlur, payload)
end
function NavigationEventManager:render()
return nil
end
return NavigationEventManager
@@ -0,0 +1,203 @@
local root = script.Parent.Parent.Parent
local Packages = root.Parent
local Cryo = require(Packages.Cryo)
local TableUtilities = require(root.utils.TableUtilities)
local validate = require(root.utils.validate)
local SCENE_KEY_PREFIX = "scene_"
-- Compare two scenes based upon index and view key.
local function compareScenes(a, b)
if a.index == b.index then
-- compare the route keys
local delta = #a.key - #b.key
if delta == 0 then
return a.key < b.key
else
return delta < 0
end
else
-- rank by index first
return a.index < b.index
end
end
local function routesAreShallowEqual(a, b)
if not a or not b then
return a == b
end
if a.key ~= b.key then
return false
end
return TableUtilities.ShallowEqual(a, b)
end
local function scenesAreShallowEqual(a, b)
return
a.key == b.key and
a.index == b.index and
a.isStale == b.isStale and
a.isActive == b.isActive and
routesAreShallowEqual(a, b)
end
return function(scenes, nextState, prevState, descriptors)
-- Always update descriptors. See react-navigation's bug, here:
-- https://github.com/react-navigation/react-navigation/issues/4271
-- TODO: Do we need this? Can we do a real fix?
for _, scene in ipairs(scenes) do
local route = scene.route
if descriptors and descriptors[route.key] then
scene.descriptor = descriptors[route.key]
end
end
-- Bail out early if state is not updated
if prevState == nextState then
return scenes
end
local prevScenes = {}
local freshScenes = {}
local staleScenes = {}
-- previously stale scenes should be marked stale
for _, scene in ipairs(scenes) do
local key = scene.key
if scene.isStale then
staleScenes[key] = scene
end
prevScenes[key] = scene
end
local nextKeys = {} -- fake set!
local nextRoutes = nextState.routes
local nextRoutesLength = #nextRoutes
-- Clip nextRoutes to stop at index because index is top of stack!
if nextRoutesLength > nextState.index then
print("Warning: StackRouter provided invalid state. Index should always be the top route")
nextRoutes = Cryo.List.removeRange(nextRoutes, nextState.index, nextRoutesLength)
end
for index, route in ipairs(nextRoutes) do
local key = SCENE_KEY_PREFIX .. route.key
local descriptor = descriptors and descriptors[route.key] or nil
local scene = {
index = index,
isActive = false,
isStale = false,
key = key,
route = route,
descriptor = descriptor,
}
validate(not nextKeys[key], "navigation.state.routes[%d].key '%s' conflicts with another route!", index, key)
nextKeys[key] = true
if staleScenes[key] then
-- Previously stale scene was added to nextState, so we remove it from
-- the map of stale scenes.
staleScenes[key] = nil
end
freshScenes[key] = scene
end
if prevState then
local prevRoutes = prevState.routes
local prevRoutesLength = #prevRoutes
if prevRoutesLength > prevState.index then
print("StackRouter provided invalid state. Index should always be the top route.")
prevRoutes = Cryo.List.removeRange(prevRoutes, prevState.index, prevRoutesLength)
end
-- Search previous routes and mark any removed scenes as stale
for index, route in ipairs(prevRoutes) do
local key = SCENE_KEY_PREFIX .. route.key
-- Skip any refreshed scenes
if not freshScenes[key] then
local lastScene = nil
for _, scene in ipairs(scenes) do
if scene.route.key == route.key then
lastScene = scene
break
end
end
local descriptor = descriptors[route.key]
if lastScene then
descriptor = lastScene.descriptor
end
if descriptor then
staleScenes[key] = {
index = index,
isActive = false,
isStale = true,
key = key,
route = route,
descriptor = descriptor,
}
end
end
end
end
local nextScenes = {}
local function mergeScene(nextScene)
local key = nextScene.key
local prevScene = prevScenes[key] or nil
if prevScene and scenesAreShallowEqual(prevScene, nextScene) then
-- reuse prevScene to avoid re-render
table.insert(nextScenes, prevScene)
else
table.insert(nextScenes, nextScene)
end
end
for _, scene in pairs(staleScenes) do
mergeScene(scene)
end
for _, scene in pairs(freshScenes) do
mergeScene(scene)
end
table.sort(nextScenes, compareScenes)
local activeScenesCount = 0
for index, scene in ipairs(nextScenes) do
local isActive = not scene.isStale and scene.index == nextState.index
if isActive ~= scene.isActive then
nextScenes[index] = Cryo.Dictionary.join(scene, {
isActive = isActive,
})
end
if isActive then
activeScenesCount = activeScenesCount + 1
end
end
validate(activeScenesCount == 1, "There should only be one active scene, not %d", activeScenesCount)
-- Conditionally return nextScenes based upon shallow comparison, for performance
if #nextScenes ~= #scenes then
return nextScenes
end
for index, scene in ipairs(nextScenes) do
if not scenesAreShallowEqual(scenes[index], scene) then
return nextScenes
end
end
-- Scenes have not changed
return scenes
end
@@ -0,0 +1,43 @@
local NavigationSymbol = require(script.Parent.Parent.Parent.NavigationSymbol)
local DEFAULT_SYMBOL = NavigationSymbol("DEFAULT")
local MODAL_SYMBOL = NavigationSymbol("MODAL")
local OVERLAY_SYMBOL = NavigationSymbol("OVERLAY")
--[[
StackPresentationStyle is used with stack navigators/views to determine
the behavior of a given screen when it is pushed/popped from the stack, as
well as the visual effects/transitions applied while the view is on screen.
]]
return {
--[[
The Default presentation style draws stack cards so that they will
slide in and out from the right side of the screen one at a time. No
special visual effects are applied, and cards always fill the entire space
available. Your screen content is rendered over an opaque background color
by default, but you have the option to draw the cards with a semi- or fully
transparent background via navigationOptions. Cards always prevent
tap-through of the content underneath them (in case your navigation
container is transparent).
]]
Default = DEFAULT_SYMBOL,
--[[
The Modal presentation style causes screens to animate up/down from the
bottom of the navigation container and visually stack on top of each
other. Cards are opaque by default, but you may set navigationOptions
to make them semi- or fully transparent so that you can see the underlying
cards. Modal cards always prevent tap-through of any underlying UI, including
other cards in the same stack.
]]
Modal = MODAL_SYMBOL,
--[[
The Overlay presentation style causes screens to pop in (later they will fade in)
on top of the underlying screens. Like modals, they visually stack on top of each
other. Cards are opaque by default, but you may set navigationOptions to make
them semi- or fully transparent. Overlay cards always prevent tap-through of any
underlying UI, including other cards in the same stack.
]]
Overlay = OVERLAY_SYMBOL
}
@@ -0,0 +1,136 @@
local root = script.Parent.Parent.Parent
local Packages = root.Parent
local Cryo = require(Packages.Cryo)
local Roact = require(Packages.Roact)
local StackActions = require(root.routers.StackActions)
local StackViewLayout = require(script.Parent.StackViewLayout)
local Transitioner = require(script.Parent.Transitioner)
local StackViewTransitionConfigs = require(script.Parent.StackViewTransitionConfigs)
local StackPresentationStyle = require(script.Parent.StackPresentationStyle)
local defaultNavigationConfig = {
mode = StackPresentationStyle.Default,
}
local StackView = Roact.Component:extend("StackView")
function StackView:init()
self._doRender = function(...)
return self:_render(...)
end
self._doConfigureTransition = function(...)
return self:_configureTransition(...)
end
self._doOnTransitionStart = function(...)
return self:_onTransitionStart(...)
end
self._doOnTransitionEnd = function(...)
return self:_onTransitionEnd(...)
end
self._doOnTransitionStep = function(...)
return self:_onTransitionStep(...)
end
end
function StackView:render()
local screenProps = self.props.screenProps
local navigation = self.props.navigation
local descriptors = self.props.descriptors
-- Transitioner handles setting up the animation motors and making that data
-- available to the lower layer.
return Roact.createElement(Transitioner, {
render = self._doRender,
configureTransition = self._doConfigureTransition,
screenProps = screenProps,
navigation = navigation,
descriptors = descriptors,
onTransitionStart = self._doOnTransitionStart,
onTransitionEnd = self._doOnTransitionEnd,
onTransitionStep = self._doOnTransitionStep,
})
end
function StackView:didMount()
local navigation = self.props.navigation
if navigation.state.isTransitioning then
navigation.dispatch(StackActions.completeTransition({
key = navigation.state.key,
}))
end
end
function StackView:_render(transition, lastTransition)
local screenProps = self.props.screenProps
local navigationConfig = Cryo.Dictionary.join(defaultNavigationConfig, self.props.navigationConfig)
local descriptors = self.props.descriptors
return Roact.createElement(StackViewLayout, Cryo.Dictionary.join(navigationConfig, {
screenProps = screenProps,
descriptors = descriptors,
transitionProps = transition,
lastTransitionProps = lastTransition,
}))
end
function StackView:_configureTransition(transition, lastTransition)
return StackViewTransitionConfigs.getTransitionConfig(
self.props.navigationConfig.transitionConfig,
transition,
lastTransition,
self.props.navigationConfig.mode
).transitionSpec
end
function StackView:_onTransitionStart(transition, lastTransition)
local onTransitionStart = self.props.onTransitionStart
or self.props.navigationConfig.onTransitionStart
-- Only propagate transition changes to caller for transitions where the actual
-- index has changed. Transitioner sends updates for _all_ transitions, including
-- those to the same screen that result from animation completion events.
if onTransitionStart and transition.index ~= lastTransition.index then
onTransitionStart(transition.navigation, lastTransition.navigation)
end
end
function StackView:_onTransitionEnd(transition, lastTransition)
local navigationConfig = self.props.navigationConfig
local navigation = self.props.navigation
local onTransitionEnd = self.props.onTransitionEnd or navigationConfig.onTransitionEnd
local transitionDestKey = transition.scene.route.key
local isCurrentKey = navigation.state.routes[navigation.state.index].key == transitionDestKey
if transition.navigation.state.isTransitioning and isCurrentKey then
navigation.dispatch(StackActions.completeTransition({
key = navigation.state.key,
toChildKey = transitionDestKey,
}))
end
-- Only propagate transition changes to caller for transitions where the actual
-- index has changed. Transitioner sends updates for _all_ transitions, including
-- those to the same screen that result from animation completion events.
if onTransitionEnd and transition.index ~= lastTransition.index then
onTransitionEnd(transition.navigation, lastTransition.navigation)
end
end
function StackView:_onTransitionStep(transition, lastTransition, value)
local onTransitionStep = self.props.onTransitionStep
or self.props.navigationConfig.onTransitionStep
-- Only propagate transition changes to caller for transitions where the actual
-- index has changed. Transitioner sends updates for _all_ transitions, including
-- those to the same screen that result from animation completion events.
if onTransitionStep and transition.index ~= lastTransition.index then
onTransitionStep(transition.navigation, lastTransition.navigation, value)
end
end
return StackView
@@ -0,0 +1,116 @@
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local validate = require(script.Parent.Parent.Parent.utils.validate)
--[[
Render a scene as a card for use in a StackView. This component is
responsible for correctly positioning the scene content in relation
to the other scenes. The content will be rendered inside a Frame
whose position and size are controlled by the transition logic. The
frame may either be transparent or a solid color, depending upon props.
Any additional visual effects must be supplied by the container or the
child element created by renderScene().
Props:
renderScene(scene) -- Render prop to draw the scene inside the card.
initialPosition -- Starting position for the card. (Animated by Otter from there).
positionStep -- Stepper function from StackViewInterpolator.
position -- Otter motor for the position of the card.
scene -- Scene that the card is to render.
forceHidden -- Forcibly disable card rendering (e.g. animated off-screen).
transparent -- Card allows underlying content to show through (default: false).
cardColor3 -- Color of the card background if it's not transparent (default: white).
]]
local StackViewCard = Roact.Component:extend("StackViewCard")
StackViewCard.defaultProps = {
transparent = false,
cardColor3 = Color3.new(1, 1, 1),
}
function StackViewCard:init()
local currentNavIndex = self.props.navigation.state.index
self._isMounted = false
self._positionLastValue = currentNavIndex
local selfRef = Roact.createRef()
self._getRef = function()
return self.props[Roact.Ref] or selfRef
end
end
function StackViewCard:render()
local forceHidden = self.props.forceHidden
local cardColor3 = self.props.cardColor3
local transparent = self.props.transparent
local initialPosition = self.props.initialPosition
local renderScene = self.props.renderScene
local scene = self.props.scene
validate(type(renderScene) == "function", "renderScene must be a function")
return Roact.createElement("Frame", {
Position = initialPosition,
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = cardColor3,
BackgroundTransparency = transparent and 1 or nil,
BorderSizePixel = 0,
ClipsDescendants = true,
Visible = not forceHidden,
[Roact.Ref] = self:_getRef(),
}, {
Content = renderScene(scene),
})
end
function StackViewCard:didMount()
self._isMounted = true
local position = self.props.position
self._positionDisconnector = position:onStep(function(...)
self:_onPositionStep(...)
end)
end
function StackViewCard:willUnmount()
self._isMounted = false
if self._positionDisconnector then
self._positionDisconnector()
self._positionDisconnector = nil
end
end
function StackViewCard:didUpdate(oldProps)
local position = self.props.position
local positionStep = self.props.positionStep
if position ~= oldProps.position then
self._positionDisconnector()
self._positionDisconnector = position:onStep(function(...)
self:_onPositionStep(...)
end)
end
if positionStep ~= oldProps.positionStep then
-- The motor won't fire just because stepper function has changed. We have to
-- update the position to match new requirements based upon last motor value.
self:_onPositionStep(self._positionLastValue)
end
end
function StackViewCard:_onPositionStep(value)
if not self._isMounted then
return
end
local positionStep = self.props.positionStep
if positionStep then
positionStep(self:_getRef(), value)
end
self._positionLastValue = value
end
return StackViewCard
@@ -0,0 +1,216 @@
--[[
Provides builders to create functions that interpolate the current Otter motor
position into the correct translation for stack cards based upon their associated
scene.
Interpolator builders expect the following props as input:
{
initialPositionValue = <value of position motor that transition began from>,
scene = <scene for the particular card being animated>,
layout = {
initWidth = <expected width of card>,
initHeight = <expected height of card>,
isMeasured = <boolean: true if initWidth+Height have been measured, else false>,
}
}
Each builder returns a props table to be merged onto your other StackViewCard props, ex:
{
positionStep = <stepper function, or nil if not needed>,
initialPosition = <starting position UDim2 for card based upon current active scene index>,
forceHidden = true, -- May disable card visibility if it's outside interpolating range.
}
The props table may contain other changes, depending on the requirements of the animation.
]]
local getSceneIndicesForInterpolationInputRange = require(
script.Parent.Parent.Parent.utils.getSceneIndicesForInterpolationInputRange)
local lerp = require(script.Parent.Parent.Parent.utils.lerp)
-- Render initial style when layout hasn't been measured yet.
local function forInitial(props)
local initialPositionValue = props.initialPositionValue
local scene = props.scene
local forceHidden = initialPositionValue ~= scene.index
local translate = forceHidden and 1000000 or 0
return {
forceHidden = forceHidden,
initialPosition = UDim2.new(0, translate, 0, translate),
positionStep = nil,
}
end
-- Slide-in from right style (e.g. navigation stack view).
local function forHorizontal(props)
local initialPositionValue = props.initialPositionValue
local layout = props.layout
local scene = props.scene
if not layout.isMeasured then
return forInitial(props)
end
local interpolate = getSceneIndicesForInterpolationInputRange(props)
-- getSceneIndices* returns nil if card is not visible and need not be
-- considered for the animation until state changes.
if not interpolate then
return {
forceHidden = true,
initialPosition = UDim2.new(0, 100000, 0, 100000),
positionStep = nil,
}
end
local first = interpolate.first
local last = interpolate.last
local index = scene.index
local width = layout.initWidth
local function calculate(positionValue)
-- 3 range LERP
if positionValue < first then
return width
elseif positionValue < index then
return lerp(width, 0, (positionValue - first) / (index - first))
elseif positionValue == index then
return 0
elseif positionValue < last then
return lerp(0, -width, (positionValue - index) / (last - index))
else
return -width
end
end
local function stepper(cardRef, positionValue)
local cardInstance = cardRef.current
if not cardInstance then
return
end
local oldPosition = cardInstance.Position
cardInstance.Position = UDim2.new(
oldPosition.X.Scale,
calculate(positionValue),
oldPosition.Y.Scale,
oldPosition.Y.Offset
)
end
local initialPosition = UDim2.new(0, calculate(initialPositionValue), 0, 0)
return {
initialPosition = initialPosition,
positionStep = stepper,
}
end
-- Slide-in from bottom style (e.g. modals).
local function forVertical(props)
local initialPositionValue = props.initialPositionValue
local layout = props.layout
local scene = props.scene
if not layout.isMeasured then
return forInitial(props)
end
local interpolate = getSceneIndicesForInterpolationInputRange(props)
if not interpolate then
return {
forceHidden = true,
initialPosition = UDim2.new(0, 100000, 0, 100000),
positionStep = nil,
}
end
local first = interpolate.first
local index = scene.index
local height = layout.initHeight
local function calculate(positionValue)
-- 2 range LERP
if positionValue < first then
return height
elseif positionValue < index then
return lerp(height, 0, (positionValue - first) / (index - first))
else
return 0
end
end
local function stepper(cardRef, positionValue)
local cardInstance = cardRef.current
if not cardInstance then
return
end
local oldPosition = cardInstance.Position
cardInstance.Position = UDim2.new(
oldPosition.X.Scale,
oldPosition.X.Offset,
oldPosition.Y.Scale,
calculate(positionValue)
)
end
local initialPosition = UDim2.new(0, 0, 0, calculate(initialPositionValue))
return {
initialPosition = initialPosition,
positionStep = stepper,
}
end
-- Fade in place animation (e.g. popovers and toasts). Note that since we don't currently have
-- group transparency, this 'animation' just pops the views in for now.
local function forFade(props)
local initialPositionValue = props.initialPositionValue
local layout = props.layout
local scene = props.scene
if not layout.isMeasured then
return forInitial(props)
end
local interpolate = getSceneIndicesForInterpolationInputRange(props)
if not interpolate then
return {
forceHidden = true,
initialPosition = UDim2.new(0, 100000, 0, 100000),
positionStep = nil,
}
end
local index = scene.index
local function calculate(positionValue)
return positionValue >= index - 0.5
end
local function stepper(cardRef, positionValue)
local cardInstance = cardRef.current
if not cardInstance then
return
end
cardInstance.Visible = calculate(positionValue)
end
return {
forceHidden = not calculate(initialPositionValue),
initialPosition = UDim2.new(0, 0, 0, 0),
positionStep = stepper,
}
end
return {
forHorizontal = forHorizontal,
forVertical = forVertical,
forFade = forFade,
}
@@ -0,0 +1,265 @@
local Cryo = require(script.Parent.Parent.Parent.Parent.Cryo)
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local StackPresentationStyle = require(script.Parent.StackPresentationStyle)
local StackViewTransitionConfigs = require(script.Parent.StackViewTransitionConfigs)
local StackViewOverlayFrame = require(script.Parent.StackViewOverlayFrame)
local StackViewCard = require(script.Parent.StackViewCard)
local SceneView = require(script.Parent.Parent.SceneView)
local defaultScreenOptions = {
absorbInput = true,
overlayEnabled = false,
overlayColor3 = Color3.new(0, 0, 0),
overlayTransparency = 0.7,
-- cardColor3 default is provided by StackViewCard
renderOverlay = function(navigationOptions, initialTransitionValue, transitionChangedSignal)
-- NOTE: renderOverlay will not be called if sceneOptions.overlayEnabled evaluates false
return Roact.createElement(StackViewOverlayFrame, {
navigationOptions = navigationOptions,
initialTransitionValue = initialTransitionValue,
transitionChangedSignal = transitionChangedSignal,
})
end,
}
local function calculateTransitionValue(index, position)
return math.max(math.min(1 + position - index, 1), 0)
end
local StackViewLayout = Roact.Component:extend("StackViewLayout")
function StackViewLayout:init()
local startingIndex = self.props.transitionProps.navigation.state.index
self._isMounted = false
self._positionLastValue = startingIndex
self._renderScene = function(scene)
return self:_renderInnerScene(scene)
end
self._subscribeToOverlayUpdates = function(callback)
local position = self.props.transitionProps.position
local index = self.props.transitionProps.scene.index
return position:onStep(function(value)
callback(calculateTransitionValue(index, value))
end)
end
end
function StackViewLayout:_renderCard(scene, navigationOptions)
local transitionProps = self.props.transitionProps -- Core animation info from Transitioner.
local lastTransitionProps = self.props.lastTransitionProps -- Previous transition info.
local transitionConfig = self.state.transitionConfig -- State based info from scene config.
local cardColor3 = navigationOptions.cardColor3
local overlayEnabled = navigationOptions.overlayEnabled
local initialPositionValue = transitionProps.scene.index
if lastTransitionProps then
initialPositionValue = lastTransitionProps.scene.index
end
local cardInterpolationProps = {}
local screenInterpolator = transitionConfig.screenInterpolator
if screenInterpolator then
cardInterpolationProps = screenInterpolator(
Cryo.Dictionary.join(transitionProps, {
initialPositionValue = initialPositionValue,
scene = scene,
})
)
end
-- Merge down the various prop packages to be applied to StackViewCard.
return Roact.createElement(StackViewCard, Cryo.Dictionary.join(
transitionProps, cardInterpolationProps, {
key = "card_" .. tostring(scene.key),
scene = scene,
renderScene = self._renderScene,
transparent = overlayEnabled,
cardColor3 = cardColor3,
})
)
end
function StackViewLayout:_renderInnerScene(scene)
local navigation = scene.descriptor.navigation
local sceneComponent = scene.descriptor.getComponent()
local screenProps = self.props.screenProps
return Roact.createElement(SceneView, {
screenProps = screenProps,
navigation = navigation,
component = sceneComponent,
})
end
function StackViewLayout:render()
local transitionProps = self.props.transitionProps
local topMostOpaqueSceneIndex = self.state.topMostOpaqueSceneIndex
local scenes = transitionProps.scenes
local renderedScenes = Cryo.List.map(scenes, function(scene)
-- The card is obscured if:
-- It's not the active card (e.g. we're transitioning TO it).
-- It's hidden underneath an opaque card that is NOT currently transitioning.
-- It's completely off-screen.
local cardObscured = scene.index < topMostOpaqueSceneIndex and not scene.isActive
local screenOptions = Cryo.Dictionary.join(defaultScreenOptions, scene.descriptor.options or {})
local overlayEnabled = screenOptions.overlayEnabled
local absorbInput = screenOptions.absorbInput
local renderOverlay = screenOptions.renderOverlay
local stationaryContent = nil
if overlayEnabled then
stationaryContent = Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
ClipsDescendants = true,
BorderSizePixel = 0,
ZIndex = 1,
}, {
Overlay = renderOverlay(
screenOptions,
calculateTransitionValue(scene.index, self._positionLastValue),
self._subscribeToOverlayUpdates)
})
end
-- Wrapper frame holds default/custom card background and the card content.
-- It MUST be a Frame when absorbInput=false because of legacy behavior on desktop
-- for GuiObject.Active=false blocking mouse clicks from falling through.
-- (Active=false DOES work on mobile, but not desktop).
-- When absorbInput=true, we add a TextButton behind the frame that will catch
-- mouse clicks
local absorbInputElement = nil
if not cardObscured and absorbInput then
absorbInputElement = Roact.createElement("TextButton", {
Active = true,
AutoButtonColor = false,
BackgroundTransparency = 1,
BorderSizePixel = 0,
ClipsDescendants = true,
Size = UDim2.new(1, 0, 1, 0),
Text = " ",
ZIndex = 2 * scene.index - 1,
})
end
return Roact.createFragment({
AbsorbInput = absorbInputElement,
-- use scene index for key, it makes testing with Rhodium easier
[tostring(scene.index)] = Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
BorderSizePixel = 0,
ClipsDescendants = true,
ZIndex = 2 * scene.index,
Visible = not cardObscured,
}, {
StationaryContent = stationaryContent,
DynamicContent = Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
ClipsDescendants = true,
BorderSizePixel = 0,
ZIndex = 2,
}, {
-- Cards need to have unique keys so that instances of the same components are not
-- reused for different scenes. (Could lead to unanticipated lifecycle problems).
["card_" .. scene.key] = self:_renderCard(scene, screenOptions),
})
}),
})
end)
return Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
ClipsDescendants = true,
BorderSizePixel = 0,
}, renderedScenes)
end
function StackViewLayout.getDerivedStateFromProps(nextProps, lastState)
local transitionProps = nextProps.transitionProps
local scenes = transitionProps.scenes
local state = transitionProps.navigation.state
local isTransitioning = state.isTransitioning
local topMostIndex = #scenes
local isOverlayMode = nextProps.mode == StackPresentationStyle.Modal or
nextProps.mode == StackPresentationStyle.Overlay
-- Find the last opaque scene in a modal stack so that we can optimize rendering.
local topMostOpaqueSceneIndex = 0
if isOverlayMode then
for idx = topMostIndex, 1, -1 do
local scene = scenes[idx]
local navigationOptions = Cryo.Dictionary.join(defaultScreenOptions, scene.descriptor.options or {})
-- Card covers other pages if it's not an overlay and it's not the top-most index while transitioning.
if not navigationOptions.overlayEnabled and not (isTransitioning and idx == topMostIndex) then
topMostOpaqueSceneIndex = idx
break
end
end
else
for idx = topMostIndex, 1, -1 do
if not (isTransitioning and idx == topMostIndex) then
topMostOpaqueSceneIndex = idx
break
end
end
end
return {
topMostOpaqueSceneIndex = topMostOpaqueSceneIndex,
transitionConfig = StackViewTransitionConfigs.getTransitionConfig(
nextProps.transitionConfig,
nextProps.transitionProps,
nextProps.lastTransitionProps,
nextProps.mode),
}
end
function StackViewLayout:didMount()
self._isMounted = true
self._positionDisconnector = self.props.transitionProps.position:onStep(function(...)
self:_onPositionStep(...)
end)
end
function StackViewLayout:willUnmount()
self._isMounted = false
if self._positionDisconnector then
self._positionDisconnector()
self._positionDisconnector = nil
end
end
function StackViewLayout:didUpdate(oldProps)
local position = self.props.transitionProps.position
if position ~= oldProps.transitionProps.position then
self._positionDisconnector()
self._positionDisconnector = position:onStep(function(...)
self:_onPositionStep(...)
end)
end
end
function StackViewLayout:_onPositionStep(value)
if self._isMounted then
self._positionLastValue = value
end
end
return StackViewLayout
@@ -0,0 +1,71 @@
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local lerp = require(script.Parent.Parent.Parent.utils.lerp)
local StackViewOverlayFrame = Roact.Component:extend("StackViewOverlayFrame")
function StackViewOverlayFrame:init()
self._signalDisconnect = nil
local selfRef = Roact.createRef()
self._getRef = function()
return self.props[Roact.Ref] or selfRef
end
end
function StackViewOverlayFrame:render()
local navigationOptions = self.props.navigationOptions
local initialTransitionValue = self.props.initialTransitionValue
local overlayTransparency = lerp(1, navigationOptions.overlayTransparency, initialTransitionValue)
return Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = navigationOptions.overlayColor3,
BackgroundTransparency = overlayTransparency,
BorderSizePixel = 0,
[Roact.Ref] = self:_getRef(),
})
end
function StackViewOverlayFrame:didUpdate(oldProps)
local transitionChangedSignal = self.props.transitionChangedSignal
if transitionChangedSignal ~= oldProps.transitionChangedSignal then
if self._signalDisconnect then
self._signalDisconnect()
end
self._signalDisconnect = transitionChangedSignal(function(...)
self:_transitionChanged(...)
end)
end
end
function StackViewOverlayFrame:didMount()
self._isMounted = true
self._signalDisconnect = self.props.transitionChangedSignal(function(...)
self:_transitionChanged(...)
end)
end
function StackViewOverlayFrame:willUnmount()
self._isMounted = false
if self._signalDisconnect then
self._signalDisconnect()
end
end
function StackViewOverlayFrame:_transitionChanged(value)
if not self._isMounted then
return
end
local myRef = self:_getRef()
if myRef.current then
local navigationOptions = self.props.navigationOptions
local overlayTransparency = lerp(1, navigationOptions.overlayTransparency, value)
myRef.current.BackgroundTransparency = overlayTransparency
end
end
return StackViewOverlayFrame
@@ -0,0 +1,53 @@
local Cryo = require(script.Parent.Parent.Parent.Parent.Cryo)
local StackViewInterpolator = require(script.Parent.StackViewInterpolator)
local StackPresentationStyle = require(script.Parent.StackPresentationStyle)
local DefaultTransitionSpec = {
frequency = 3, -- Hz
dampingRatio = 1,
}
local SlideFromRight = {
transitionSpec = DefaultTransitionSpec,
screenInterpolator = StackViewInterpolator.forHorizontal,
}
local ModalSlideFromBottom = {
transitionSpec = DefaultTransitionSpec,
screenInterpolator = StackViewInterpolator.forVertical,
}
local FadeInPlace = {
transitionSpec = DefaultTransitionSpec,
screenInterpolator = StackViewInterpolator.forFade,
}
local function getDefaultTransitionConfig(transitionProps, prevTransitionProps, presentationStyle)
if presentationStyle == StackPresentationStyle.Modal then
return ModalSlideFromBottom
elseif presentationStyle == StackPresentationStyle.Overlay then
return FadeInPlace
else
return SlideFromRight
end
end
local function getTransitionConfig(transitionConfigurer, transitionProps, prevTransitionProps, presentationStyle)
local defaultConfig = getDefaultTransitionConfig(transitionProps, prevTransitionProps, presentationStyle)
if transitionConfigurer then
return Cryo.Dictionary.join(
defaultConfig,
transitionConfigurer(transitionProps, prevTransitionProps, presentationStyle)
)
end
return defaultConfig
end
return {
getDefaultTransitionConfig = getDefaultTransitionConfig,
getTransitionConfig = getTransitionConfig,
SlideFromRight = SlideFromRight,
ModalSlideFromBottom = ModalSlideFromBottom,
FadeInPlace = FadeInPlace,
}
@@ -0,0 +1,312 @@
local root = script.Parent.Parent.Parent
local Packages = root.Parent
local Cryo = require(Packages.Cryo)
local Roact = require(Packages.Roact)
local Otter = require(Packages.Otter)
local ScenesReducer = require(script.Parent.ScenesReducer)
local validate = require(root.utils.validate)
local DEFAULT_TRANSITION_SPEC = {
frequency = 4 -- Hz
}
local function buildTransitionProps(props, state)
local navigation = props.navigation
local options = props.options
local layout = state.layout
local position = state.position
local scenes = state.scenes
local activeScene
for _, x in ipairs(scenes) do
if x.isActive then
activeScene = x
break
end
end
validate(activeScene, "Could not find active scene")
return {
layout = layout,
navigation = navigation,
position = position,
scenes = scenes,
scene = activeScene,
options = options,
index = activeScene.index,
}
end
local function filterStale(scenes)
local filtered = Cryo.List.filter(scenes, function(scene)
return not scene.isStale
end)
if #filtered == #scenes then
return scenes
else
return filtered
end
end
local Transitioner = Roact.Component:extend("Transitioner")
function Transitioner:init()
local navigationState = self.props.navigation.state
local descriptors = self.props.descriptors
self.state = {
-- Layout is passed to StackViewLayout in order to allow it to
-- sync animations.
layout = {
initWidth = 0,
initHeight = 0,
isMeasured = false,
},
position = Otter.createSingleMotor(navigationState.index),
scenes = ScenesReducer({}, navigationState, nil, descriptors),
}
self._doOnAbsoluteSizeChanged = function(...)
return self:_onAbsoluteSizeChanged(...)
end
self._positionLastValue = navigationState.index
self._prevTransitionProps = nil
self._transitionProps = buildTransitionProps(self.props, self.state)
self._isMounted = false
self._isTransitionRunning = false
self._transitionQueue = {}
self._completeSignalDisconnector = self.state.position:onComplete(function()
-- This spawn is required because of this Otter bug: https://github.com/Roblox/otter/issues/26
-- Otter.SingleMotor's step function calls onComplete before it calls stop(). This leaves their
-- __running=true, and the setGoal() in our _onTransitionEnd() does nothing. So the whole queue
-- handling just stops cold, and the transition never actually happens!
spawn(function()
if self._isMounted then
self:_onTransitionEnd()
end
end)
end)
self._stepSignalDisconnector = self.state.position:onStep(function(value)
if self._isMounted then
self:_onPositionStep(value)
end
end)
end
function Transitioner:didMount()
self._isMounted = true
end
function Transitioner:willUnmount()
self._isMounted = false
if self._completeSignalDisconnector then
self._completeSignalDisconnector()
self._completeSignalDisconnector = nil
end
if self._stepSignalDisconnector then
self._stepSignalDisconnector()
self._stepSignalDisconnector = nil
end
end
function Transitioner:didUpdate(prevProps)
-- React-navigation uses componentWillReceiveProps that is only called when Parent
-- re-renders or when this component is actually being given new props, so we need to
-- filter here. If not, this would trigger on setState and enter an infinite loop.
if self.props ~= prevProps then
if self._isTransitionRunning then
local mostRecentTransition = self._transitionQueue[#self._transitionQueue] or {}
-- don't enqueue spurious extra copies of same transition props
if mostRecentTransition.prevProps ~= prevProps then
table.insert(self._transitionQueue, { prevProps = prevProps })
end
return
end
self:_startTransition(prevProps, self.props)
end
end
function Transitioner:render()
return Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
BorderSizePixel = 0,
ClipsDescendants = true,
[Roact.Change.AbsoluteSize] = self._doOnAbsoluteSizeChanged,
}, {
["TransitionerScenes"] = self.props.render(
self._transitionProps, self._prevTransitionProps),
})
end
-- equivalent to React-Nav's Transitioner._onLayout
function Transitioner:_onAbsoluteSizeChanged(rbx)
local width = rbx.AbsoluteSize.X
local height = rbx.AbsoluteSize.Y
if width == self.state.layout.initWidth and
height == self.state.layout.initHeight then
return
end
local layout = Cryo.Dictionary.join(self.state.layout, {
initWidth = width,
initHeight = height,
isMeasured = true,
})
local nextState = Cryo.Dictionary.join(self.state, {
layout = layout,
})
self._transitionProps = buildTransitionProps(self.props, nextState)
self:setState({
layout = layout,
})
end
function Transitioner:_computeScenes(props, nextProps)
local nextScenes = ScenesReducer(
self.state.scenes,
nextProps.navigation.state,
props.navigation.state,
nextProps.descriptors)
if not nextProps.navigation.state.isTransitioning then
nextScenes = filterStale(nextScenes)
end
if nextScenes == self.state.scenes then
return nil
end
return nextScenes
end
function Transitioner:_startTransition(props, nextProps)
local indexHasChanged = props.navigation.state.index ~= nextProps.navigation.state.index
local nextScenes = self:_computeScenes(props, nextProps)
if not nextScenes then
-- If nextScenes is nil, nothing has changed, so report transition end, then bail
self._prevTransitionProps = self._transitionProps
-- Trigger end transition logic if we daisy-chained from queued transitions via _onTransitionEnd.
if self._isTransitionRunning then
self:_onTransitionEnd()
end
return
end
local nextState = Cryo.Dictionary.join(self.state, {
scenes = nextScenes,
})
local position = nextState.position
local toValue = nextProps.navigation.state.index
-- compute transitionProps
self._prevTransitionProps = self._transitionProps
self._transitionProps = buildTransitionProps(nextProps, nextState)
local isTransitioning = self._transitionProps.navigation.state.isTransitioning
if not isTransitioning or not indexHasChanged then
-- If state is not transitioning, then we go immediately to new index.
-- Likewise, if the index has not changed then we still need to set up initial
-- positions via setState.
self:setState(nextState)
if nextProps.onTransitionStart then
nextProps.onTransitionStart(self._transitionProps, self._prevTransitionProps)
end
-- motor will call _endTransition for us
position:setGoal(Otter.instant(toValue))
elseif isTransitioning then
self._isTransitionRunning = true
self:setState(nextState)
if nextProps.onTransitionStart then
nextProps.onTransitionStart(self._transitionProps, self._prevTransitionProps)
end
local positionHasChanged = self._positionLastValue ~= toValue
if indexHasChanged and positionHasChanged then
-- get transition spec
local transitionUserSpec = {}
if nextProps.configureTransition then
transitionUserSpec = nextProps.configureTransition(
self._transitionProps, self._prevTransitionProps) or {}
end
local transitionSpec = Cryo.Dictionary.join(DEFAULT_TRANSITION_SPEC, transitionUserSpec)
-- motor will call _endTransition for us
position:setGoal(Otter.spring(nextProps.navigation.state.index, transitionSpec))
else
-- Set motor to current state to trigger _endTransition call with correct sequencing.
position:setGoal(Otter.instant(nextProps.navigation.state.index))
end
end
end
function Transitioner:_onTransitionEnd()
local prevTransitionProps = self._prevTransitionProps
self._prevTransitionProps = nil
local scenes = filterStale(self.state.scenes)
local nextState = Cryo.Dictionary.join(self.state, {
scenes = scenes,
})
self._transitionProps = buildTransitionProps(self.props, nextState)
self:setState(nextState)
if self.props.onTransitionEnd then
self.props.onTransitionEnd(self._transitionProps, prevTransitionProps)
end
local firstQueuedTransition = self._transitionQueue[1]
if firstQueuedTransition then
local prevProps = firstQueuedTransition.prevProps
self._transitionQueue = Cryo.List.removeIndex(self._transitionQueue, 1)
self:_startTransition(prevProps, self.props)
else
self._isTransitionRunning = false
end
end
function Transitioner:_onPositionStep(value)
self._positionLastValue = value
local targetIndex = self._transitionProps.index
-- _prevTransitionProps can be nil, so guard against it.
local startingIndex = targetIndex
if self._prevTransitionProps then
startingIndex = self._prevTransitionProps.index
end
if self.props.onTransitionStep and startingIndex ~= targetIndex then
local transitionValue = (value - startingIndex) / (targetIndex - startingIndex)
self.props.onTransitionStep(self._transitionProps, self._prevTransitionProps, transitionValue)
end
end
return Transitioner
@@ -0,0 +1,266 @@
return function()
-- ScenesReducer(scenes, nextState, prevState, descriptors)
local ScenesReducer = require(script.Parent.Parent.ScenesReducer)
local initialRouteKey = "id-1"
local initialRouteName = "First Route"
local initialRoute = {
routeName = initialRouteName,
key = initialRouteKey,
}
local initialState = {
index = 1,
key = "StackRouterRoot",
isTransitioning = false,
routes = {
initialRoute,
},
}
local initialDescriptors = {
[initialRouteKey] = {
key = initialRouteKey,
navigation = {
state = initialRoute,
},
state = initialRoute,
},
}
local initialScenes = nil
it("should generate valid initial scene", function()
local scenes = ScenesReducer({}, initialState, nil, initialDescriptors)
local sceneOne = scenes[1]
expect(#scenes).to.be.equal(1)
expect(sceneOne.route).to.be.equal(initialRoute)
expect(sceneOne.index).to.be.equal(1)
expect(sceneOne.isActive).to.be.equal(true)
expect(sceneOne.isStale).to.be.equal(false)
initialScenes = scenes
end)
it("should update descriptor", function()
local scenes = ScenesReducer({}, initialState, nil, initialDescriptors)
local dummyDescriptor = { key = "this is a dummy descriptor" }
scenes[1].descriptor = dummyDescriptor
local updatedScenes = ScenesReducer(scenes, initialState, initialState, initialDescriptors)
expect(#updatedScenes).to.be.equal(1)
local sceneOne = updatedScenes[1]
expect(sceneOne.descriptor).to.never.be.equal(dummyDescriptor)
expect(sceneOne.descriptor).to.be.equal(initialDescriptors[initialRouteKey])
expect(sceneOne.route).to.be.equal(initialRoute)
expect(sceneOne.index).to.be.equal(1)
expect(sceneOne.isActive).to.be.equal(true)
expect(sceneOne.isStale).to.be.equal(false)
end)
it("should bail out early", function()
expect(initialScenes).to.never.be.equal(nil)
local scenes = ScenesReducer(initialScenes, nil, nil, nil)
expect(scenes).to.be.equal(initialScenes)
scenes = ScenesReducer(initialScenes, initialState, initialState, initialDescriptors)
expect(scenes).to.be.equal(initialScenes)
end)
local secondRouteKey = "id-2"
local secondRouteName = "Second Route"
local secondRoute = {
key = secondRouteKey,
routeName = secondRouteName,
}
local secondState = {
index = 2,
key = "StackRouterRoot",
isTransitioning = true,
routes = {
initialRoute,
secondRoute,
},
}
local secondDescriptors = {
[initialRouteKey] = initialDescriptors[initialRouteKey],
[secondRouteKey] ={
key = secondRouteKey,
navigation = {
state = secondRoute,
},
state = secondRoute,
},
}
local secondScenes = nil
it("should add second scene", function()
expect(initialScenes).to.never.be.equal(nil)
local scenes = ScenesReducer(initialScenes, secondState, initialState, secondDescriptors)
expect(scenes).to.never.be.equal(initialScenes)
expect(#scenes).to.be.equal(2)
local sceneOne = scenes[1]
expect(sceneOne.route).to.be.equal(initialRoute)
expect(sceneOne.index).to.be.equal(1)
expect(sceneOne.isActive).to.be.equal(false)
expect(sceneOne.isStale).to.be.equal(false)
local sceneTwo = scenes[2]
expect(sceneTwo.route).to.be.equal(secondRoute)
expect(sceneTwo.index).to.be.equal(2)
expect(sceneTwo.isActive).to.be.equal(true)
expect(sceneTwo.isStale).to.be.equal(false)
secondScenes = scenes
end)
local thirdRouteKey = "id-3"
local thirdRouteName = "Third Route"
local thirdRoute = {
key = thirdRouteKey,
routeName = thirdRouteName,
}
local thirdState = {
index = 3,
key = "StackRouterRoot",
isTransitioning = true,
routes = {
initialRoute,
secondRoute,
thirdRoute,
},
}
local thirdDescriptors = {
[initialRouteKey] = initialDescriptors[initialRouteKey],
[secondRouteKey] = secondDescriptors[secondRouteKey],
[thirdRouteKey] = {
key = thirdRouteKey,
navigation = {
state = thirdRoute,
},
state = thirdRoute,
},
}
local thirdScenes = nil
it("should add third scene", function()
expect(initialScenes).to.never.be.equal(nil)
local scenes = ScenesReducer(secondScenes, thirdState, secondState, thirdDescriptors)
expect(scenes).to.never.be.equal(initialScenes)
expect(#scenes).to.be.equal(3)
local sceneOne = scenes[1]
expect(sceneOne.route).to.be.equal(initialRoute)
expect(sceneOne.index).to.be.equal(1)
expect(sceneOne.isActive).to.be.equal(false)
expect(sceneOne.isStale).to.be.equal(false)
local sceneTwo = scenes[2]
expect(sceneTwo.route).to.be.equal(secondRoute)
expect(sceneTwo.index).to.be.equal(2)
expect(sceneTwo.isActive).to.be.equal(false)
expect(sceneTwo.isStale).to.be.equal(false)
local sceneThree = scenes[3]
expect(sceneThree.route).to.be.equal(thirdRoute)
expect(sceneThree.index).to.be.equal(3)
expect(sceneThree.isActive).to.be.equal(true)
expect(sceneThree.isStale).to.be.equal(false)
thirdScenes = scenes
end)
it("should mark removed scenes as stale", function()
expect(secondState).to.never.be.equal(nil)
local scenes = ScenesReducer(thirdScenes, initialState, thirdState, initialDescriptors)
expect(scenes).to.never.be.equal(initialScenes)
expect(#scenes).to.be.equal(3)
local sceneOne = scenes[1]
expect(sceneOne.route).to.be.equal(initialRoute)
expect(sceneOne.index).to.be.equal(1)
expect(sceneOne.isActive).to.be.equal(true)
expect(sceneOne.isStale).to.be.equal(false)
local sceneTwo = scenes[2]
expect(sceneTwo.route).to.be.equal(secondRoute)
expect(sceneTwo.index).to.be.equal(2)
expect(sceneTwo.isActive).to.be.equal(false)
expect(sceneTwo.isStale).to.be.equal(true)
local sceneThree = scenes[3]
expect(sceneThree.route).to.be.equal(thirdRoute)
expect(sceneThree.index).to.be.equal(3)
expect(sceneThree.isActive).to.be.equal(false)
expect(sceneThree.isStale).to.be.equal(true)
end)
local secondScreenReplacementKey = "id-22"
local secondScreenReplacementName = "Second Route Replacement"
local secondScreenReplacementRoute = {
key = secondScreenReplacementKey,
routeName = secondScreenReplacementName,
}
local replacedSecondSceneState = {
index = 3,
key = "StackRouterRoot",
isTransitioning = true,
routes = {
initialRoute,
secondScreenReplacementRoute,
thirdRoute,
},
}
local replacedSceneDescriptors = {
[initialRouteKey] = initialDescriptors[initialRouteKey],
[secondRouteKey] = {
key = secondScreenReplacementKey,
navigation = {
state = secondScreenReplacementRoute
},
state = secondScreenReplacementRoute,
},
[thirdRouteKey] = thirdDescriptors[thirdRouteKey],
}
it("should mark replaced scene as stale", function()
expect(secondState).to.never.be.equal(nil)
local scenes = ScenesReducer(thirdScenes, replacedSecondSceneState, thirdState, replacedSceneDescriptors)
expect(scenes).to.never.be.equal(initialScenes)
expect(#scenes).to.be.equal(4) -- replaced scene is marked stale, it is not removed
local sceneOne = scenes[1]
expect(sceneOne.route).to.be.equal(initialRoute)
expect(sceneOne.index).to.be.equal(1)
expect(sceneOne.isActive).to.be.equal(false)
expect(sceneOne.isStale).to.be.equal(false)
local sceneTwo = scenes[2]
expect(sceneTwo.route).to.be.equal(secondRoute)
expect(sceneTwo.index).to.be.equal(2)
expect(sceneTwo.isActive).to.be.equal(false)
expect(sceneTwo.isStale).to.be.equal(true)
-- because of comparison algorithm in SceneReducer.lua compareScenes
-- the replacement scene is after the scene it replaced because id-2 < id-22
local sceneTwoReplacement = scenes[3]
expect(sceneTwoReplacement.route).to.be.equal(secondScreenReplacementRoute)
-- index is still 2 because the scene index come from route index in the nextState
-- this is ok because filterStale in Transitioner.lua will remove the stale scene
expect(sceneTwoReplacement.index).to.be.equal(2)
expect(sceneTwoReplacement.isActive).to.be.equal(false)
expect(sceneTwoReplacement.isStale).to.be.equal(false)
local sceneThree = scenes[4]
expect(sceneThree.route).to.be.equal(thirdRoute)
expect(sceneThree.index).to.be.equal(3)
expect(sceneThree.isActive).to.be.equal(true)
expect(sceneThree.isStale).to.be.equal(false)
end)
end
@@ -0,0 +1,17 @@
return function()
local StackPresentationStyle = require(script.Parent.Parent.StackPresentationStyle)
describe("StackPresentationStyle token tests", function()
it("should return same object for each token for multiple calls", function()
expect(StackPresentationStyle.Default).to.equal(StackPresentationStyle.Default)
expect(StackPresentationStyle.Modal).to.equal(StackPresentationStyle.Modal)
expect(StackPresentationStyle.Overlay).to.equal(StackPresentationStyle.Overlay)
end)
it("should return matching string names for symbols", function()
expect(tostring(StackPresentationStyle.Default)).to.equal("DEFAULT")
expect(tostring(StackPresentationStyle.Modal)).to.equal("MODAL")
expect(tostring(StackPresentationStyle.Overlay)).to.equal("OVERLAY")
end)
end)
end
@@ -0,0 +1,36 @@
return function()
local Otter = require(script.Parent.Parent.Parent.Parent.Parent.Otter)
local Roact = require(script.Parent.Parent.Parent.Parent.Parent.Roact)
local StackViewCard = require(script.Parent.Parent.StackViewCard)
it("should mount its renderProp and pass it scene", function()
local didRender = false
local testScene = {
isActive = true,
index = 1,
}
local renderedScene = nil
local element = Roact.createElement(StackViewCard, {
renderScene = function(theScene)
renderedScene = theScene
return Roact.createElement(function()
didRender = true -- verifies component is attached to tree
end)
end,
scene = testScene,
position = Otter.createSingleMotor(1),
navigation = {
state = {
index = 1,
}
}
})
local instance = Roact.mount(element)
Roact.unmount(instance)
expect(renderedScene).to.equal(testScene)
expect(didRender).to.equal(true)
end)
end
@@ -0,0 +1,68 @@
local Cryo = require(script.Parent.Parent.Parent.Cryo)
local Roact = require(script.Parent.Parent.Parent.Roact)
local SceneView = require(script.Parent.SceneView)
local defaultNavigationConfig = {
keepVisitedScreensMounted = false,
}
local RobloxSwitchView = Roact.Component:extend("RobloxSwitchView")
function RobloxSwitchView.getDerivedStateFromProps(nextProps, prevState)
local navState = nextProps.navigation.state
local activeKey = navState.routes[navState.index].key
local descriptors = nextProps.descriptors
local navigationConfig = Cryo.Dictionary.join(defaultNavigationConfig, nextProps.navigationConfig or {})
local keepVisitedScreensMounted = navigationConfig.keepVisitedScreensMounted
local visitedScreenKeys = {
[activeKey] = true
}
if keepVisitedScreensMounted then
-- prune visited screen keys if they are not included in incoming descriptors
for prevKey in pairs(prevState.visitedScreenKeys or {}) do
if descriptors[prevKey] ~= nil then
visitedScreenKeys[prevKey] = true
end
end
end
return {
visitedScreenKeys = visitedScreenKeys,
}
end
function RobloxSwitchView:render()
local navState = self.props.navigation.state
local screenProps = self.props.screenProps
local descriptors = self.props.descriptors
local visitedScreenKeys = self.state.visitedScreenKeys
local activeKey = navState.routes[navState.index].key
local screenElements = {}
for key, descriptor in pairs(descriptors) do
local isActiveKey = (key == activeKey)
if visitedScreenKeys[key] == true then
screenElements["card_" .. key] = Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
ClipsDescendants = true,
BorderSizePixel = 0,
Visible = isActiveKey,
}, {
Content = Roact.createElement(SceneView, {
component = descriptor.getComponent(),
navigation = descriptor.navigation,
screenProps = screenProps,
})
})
end
end
return Roact.createElement("Folder", nil, screenElements)
end
return RobloxSwitchView

Some files were not shown because too many files have changed in this diff Show More