From 9c4699eccff9d1cba19edc9e5cc0967728bc5c19 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 14 Mar 2021 19:52:18 +0100 Subject: [PATCH] Router: --- packages/router/README.md | 4 +- packages/router/src/Set.tsx | 52 +++ packages/router/src/__tests__/router.test.tsx | 225 +++++++++- packages/router/src/__tests__/set.test.tsx | 85 ++++ packages/router/src/history.tsx | 6 +- packages/router/src/index.ts | 2 + packages/router/src/internal.ts | 1 - packages/router/src/location.tsx | 30 +- packages/router/src/named-routes.tsx | 44 -- packages/router/src/private-context.tsx | 46 ++ packages/router/src/router-context.tsx | 72 +++ packages/router/src/router.tsx | 409 +++++++++--------- packages/router/src/util.ts | 26 +- 13 files changed, 723 insertions(+), 279 deletions(-) create mode 100644 packages/router/src/Set.tsx create mode 100644 packages/router/src/__tests__/set.test.tsx delete mode 100644 packages/router/src/named-routes.tsx create mode 100644 packages/router/src/private-context.tsx create mode 100644 packages/router/src/router-context.tsx diff --git a/packages/router/README.md b/packages/router/README.md index 180b1cc365fd..52149b99997d 100644 --- a/packages/router/README.md +++ b/packages/router/README.md @@ -4,11 +4,11 @@ This is the built-in router for Redwood apps. It takes inspiration from Ruby on > **WARNING:** RedwoodJS software has not reached a stable version 1.0 and should not be considered suitable for production use. In the "make it work; make it right; make it fast" paradigm, Redwood is in the later stages of the "make it work" phase. -Redwood Router (RR from now on) is designed to list all routes in a single file, without any nesting. We prefer this design, as it makes it very easy to track which routes map to which pages. +Redwood Router (RR from now on) is designed to list all routes in a single file, with limited nesting. We prefer this design, as it makes it very easy to track which routes map to which pages. ## Router and Route -The first thing you need is a `Router`. It will contain all of your routes. RR will attempt to match the current URL to each route in turn, stopping when it finds a match, and rendering that route only. The only exception to this is the `notfound` route, which can be placed anywhere in the list and only matches when no other routes do. +The first thing you need is a `Router`. It will contain all of your routes. RR will attempt to match the current URL to each route in turn, and only render those with a matching `path`. The only exception to this is the `notfound` route, which can be placed anywhere in the list and only matches when no other routes do. Each route is specified with a `Route`. Our first route will tell RR what to render when no other route matches: diff --git a/packages/router/src/Set.tsx b/packages/router/src/Set.tsx new file mode 100644 index 000000000000..14a7200743b4 --- /dev/null +++ b/packages/router/src/Set.tsx @@ -0,0 +1,52 @@ +import React, { ReactElement, ReactNode, FunctionComponentElement } from 'react' + +import { useLocation } from './location' +import { isRoute } from './router' +import { useRouterState } from './router-context' +import { flattenAll, matchPath } from './util' + +interface PropsWithChildren { + children: ReactNode +} + +type WrapperType = (props: { children: any }) => ReactElement | null +type ReduceType = FunctionComponentElement | undefined + +interface Props { + wrap: WrapperType | WrapperType[] + children: ReactNode +} + +export const Set: React.FC = ({ children, wrap }) => { + const routerState = useRouterState() + const location = useLocation() + const wrappers = Array.isArray(wrap) ? wrap : [wrap] + + const flatChildArray = flattenAll(children) + + const matchingChildRoute = flatChildArray.some((child) => { + if (isRoute(child)) { + const { path } = child.props + + if (path) { + const { match } = matchPath( + path, + location.pathname, + routerState.paramTypes + ) + + if (match) { + return true + } + } + } + + return false + }) + + return matchingChildRoute + ? wrappers.reduceRight((acc, wrapper) => { + return React.createElement(wrapper, undefined, acc ? acc : children) + }, undefined) || null + : null +} diff --git a/packages/router/src/__tests__/router.test.tsx b/packages/router/src/__tests__/router.test.tsx index 23c8ec6c4dd1..5d6fe327a346 100644 --- a/packages/router/src/__tests__/router.test.tsx +++ b/packages/router/src/__tests__/router.test.tsx @@ -1,10 +1,12 @@ -import { render, waitFor, act } from '@testing-library/react' +import React from 'react' + +import { render, waitFor, act, fireEvent } from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' import { AuthContextInterface } from '@redwoodjs/auth' -import { Router, Route, Private, Redirect, navigate, routes } from '../' -import { resetNamedRoutes } from '../named-routes' +import { Router, Route, Private, Redirect, navigate, routes, Link } from '../' +import { Set } from '../Set' function createDummyAuthContextValues(partial: Partial) { const authContextValues: AuthContextInterface = { @@ -34,6 +36,7 @@ const LoginPage = () =>

Login Page

const AboutPage = () =>

About Page

const PrivatePage = () =>

Private Page

const RedirectPage = () => +const NotFoundPage = () =>

404

const mockAuth = (isAuthenticated = false) => { window.__REDWOOD__USE_AUTH = () => createDummyAuthContextValues({ @@ -44,11 +47,12 @@ const mockAuth = (isAuthenticated = false) => { beforeEach(() => { window.history.pushState({}, null, '/') - resetNamedRoutes() + Object.keys(routes).forEach((key) => delete routes[key]) }) test('inits routes and navigates as expected', async () => { mockAuth(false) + const TestRouter = () => ( @@ -58,12 +62,14 @@ test('inits routes and navigates as expected', async () => { -
param {value}
} + page={({ value, q }: { value: string; q: string }) => ( +
param {`${value}${q}`}
+ )} name="params" /> +
) @@ -76,6 +82,10 @@ test('inits routes and navigates as expected', async () => { act(() => navigate(routes.about())) await waitFor(() => screen.getByText(/About Page/i)) + // passes search params to the page + act(() => navigate(routes.params({ value: 'val', q: 'q' }))) + await waitFor(() => screen.getByText('param valq')) + // navigate to redirect page // should redirect to about act(() => navigate(routes.redirect())) @@ -84,14 +94,23 @@ test('inits routes and navigates as expected', async () => { expect(screen.queryByText(/About Page/)).toBeTruthy() }) + act(() => navigate('/redirect2/redirected?q=cue')) + await waitFor(() => screen.getByText(/param redirectedcue/i)) + + // navigate to redirect2 page + // should redirect to /param-test act(() => navigate('/redirect2/redirected')) - await waitFor(() => screen.getByText(/param redirected/i)) + await waitFor(() => screen.getByText(/param redirected/)) act(() => navigate(routes.params({ value: 'one' }))) await waitFor(() => screen.getByText(/param one/i)) act(() => navigate(routes.params({ value: 'two' }))) await waitFor(() => screen.getByText(/param two/i)) + + // Renders the notfound page + act(() => navigate('/no/route/defined')) + await waitFor(() => screen.getByText('404')) }) test('unauthenticated user is redirected away from private page', async () => { @@ -229,3 +248,195 @@ test('inits routes two private routes with a space in between and loads as expec // starts on home page await waitFor(() => screen.getByText(/Home Page/i)) }) + +test('supports ', async () => { + mockAuth(false) + const GlobalLayout = ({ children }) => ( +
+

Global Layout

+ {children} +
+ ) + + const TestRouter = () => ( + + + + + + + + + + +
param {value}
} + name="params" + /> +
+
+ ) + const screen = render() + + await waitFor(() => screen.getByText(/Global Layout/i)) + await waitFor(() => screen.getByText(/Home Page/i)) +}) + +test("Doesn't destroy when navigating inside, but does when navigating between", async () => { + interface ContextState { + contextValue: string + setContextValue: React.Dispatch> + } + + const SetContext = React.createContext(undefined) + + const SetContextProvider = ({ children }) => { + const [contextValue, setContextValue] = React.useState('initialSetValue') + + return ( + + {children} + + ) + } + + const Ctx1Page = () => { + const ctx = React.useContext(SetContext) + + React.useEffect(() => { + ctx.setContextValue('updatedSetValue') + }, [ctx]) + + return

1-{ctx.contextValue}

+ } + + const Ctx2Page = () => { + const ctx = React.useContext(SetContext) + + return

2-{ctx.contextValue}

+ } + + const Ctx3Page = () => { + const ctx = React.useContext(SetContext) + + return

3-{ctx.contextValue}

+ } + + const TestRouter = () => { + return ( + + + + + + + + + + + ) + } + + const screen = render() + + await waitFor(() => screen.getByText('Home Page')) + + act(() => navigate(routes.ctx1())) + await waitFor(() => screen.getByText('1-updatedSetValue')) + + act(() => navigate(routes.ctx2())) + await waitFor(() => screen.getByText('2-updatedSetValue')) + + act(() => navigate(routes.ctx3())) + await waitFor(() => screen.getByText('3-initialSetValue')) +}) + +test('can use named routes for navigating', async () => { + const MainLayout = ({ children }) => { + return ( +
+

Main Layout

+ Home-link + About-link +
+ {children} +
+ ) + } + + const TestRouter = () => ( + + + + + + + ) + + const screen = render() + + // starts on home page, with MainLayout + await waitFor(() => screen.getByText(/Home Page/)) + await waitFor(() => screen.getByText(/Main Layout/)) + + fireEvent.click(screen.getByText('About-link')) + await waitFor(() => screen.getByText(/About Page/)) +}) + +test('renders only active path', async () => { + const AboutLayout = ({ children }) => { + return ( +
+

About Layout

+
+ {children} +
+ ) + } + + const LoginLayout = ({ children }) => { + return ( +
+

Login Layout

+
+ {children} +
+ ) + } + + const TestRouter = () => ( + + + + + + + + + + ) + + const screen = render() + + // starts on home page, with no layout + await waitFor(() => screen.getByText(/Home Page/)) + expect(screen.queryByText('About Layout')).not.toBeInTheDocument() + expect(screen.queryByText('Login Layout')).not.toBeInTheDocument() + + // go to about page, with only about layout + act(() => navigate(routes.about())) + await waitFor(() => screen.getByText(/About Page/)) + expect(screen.queryByText('About Layout')).toBeInTheDocument() + expect(screen.queryByText('Login Layout')).not.toBeInTheDocument() + + // go to login page, with only login layout + act(() => navigate(routes.login())) + await waitFor(() => screen.getByText(/Login Page/)) + expect(screen.queryByText('About Layout')).not.toBeInTheDocument() + expect(screen.queryByText('Login Layout')).toBeInTheDocument() +}) diff --git a/packages/router/src/__tests__/set.test.tsx b/packages/router/src/__tests__/set.test.tsx new file mode 100644 index 000000000000..4550bfcff13d --- /dev/null +++ b/packages/router/src/__tests__/set.test.tsx @@ -0,0 +1,85 @@ +import React from 'react' + +import { render, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom/extend-expect' + +import { Route, Router } from '../router' +import { Set } from '../Set' + +// SETUP +const ChildA = () =>

ChildA

+const ChildB = () =>

ChildB

+const ChildC = () =>

ChildC

+const GlobalLayout: React.FC = ({ children }) => ( +
+

Global Layout

+ {children} +
This is a footer
+
+) +const CustomWrapper: React.FC = ({ children }) => ( +
+

Custom Wrapper

+ {children} +

Custom Wrapper End

+
+) +const BLayout = ({ children }) => ( +
+

Layout for B

+ {children} +
+) + +test('wraps components in other components', async () => { + const TestSet = () => ( + + + + + + + + + + ) + + const screen = render() + + await waitFor(() => screen.getByText('ChildB')) + + expect(screen.container).toMatchInlineSnapshot(` +
+
+

+ Custom Wrapper +

+
+

+ Global Layout +

+

+ ChildA +

+
+

+ Layout for B +

+

+ ChildB +

+
+
+ This is a footer +
+
+

+ Custom Wrapper End +

+
+

+ ChildC +

+
+ `) +}) diff --git a/packages/router/src/history.tsx b/packages/router/src/history.tsx index 0fe3d3d28043..0576ad75a959 100644 --- a/packages/router/src/history.tsx +++ b/packages/router/src/history.tsx @@ -7,11 +7,11 @@ const createHistory = () => { listen: (listener: Listener) => { const listenerId = 'RW_HISTORY_LISTENER_ID_' + Date.now() listeners[listenerId] = listener - window?.addEventListener('popstate', listener) + global.addEventListener('popstate', listener) return listenerId }, navigate: (to: string) => { - window?.history.pushState({}, '', to) + global.history.pushState({}, '', to) for (const listener of Object.values(listeners)) { listener() } @@ -19,7 +19,7 @@ const createHistory = () => { remove: (listenerId: string) => { if (listeners[listenerId]) { const listener = listeners[listenerId] - window?.removeEventListener('popstate', listener) + global.removeEventListener('popstate', listener) delete listeners[listenerId] } else { console.warn( diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 43d51445df38..28a8424e6ff5 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -18,4 +18,6 @@ export { PageLoadingContext, } from './internal' +export * from './Set' + export { usePageLoadingContext } from './page-loader' diff --git a/packages/router/src/internal.ts b/packages/router/src/internal.ts index ef2aab6c9f52..69066805c5a2 100644 --- a/packages/router/src/internal.ts +++ b/packages/router/src/internal.ts @@ -1,7 +1,6 @@ export * from './util' export * from './history' export * from './location' -export * from './named-routes' export * from './router' export * from './params' export * from './links' diff --git a/packages/router/src/location.tsx b/packages/router/src/location.tsx index 4b3f8de31938..50324c73e1c8 100644 --- a/packages/router/src/location.tsx +++ b/packages/router/src/location.tsx @@ -48,36 +48,22 @@ class LocationProvider extends React.Component { } render() { - const { children } = this.props - const { context } = this.state - return ( - - {typeof children === 'function' ? children(context) : children || null} + + {this.props.children} ) } } -interface LocationProps { - children: (context: LocationContextType) => React.ReactChild -} - -const Location = ({ children }: LocationProps) => ( - - {(context) => - context ? ( - children(context) - ) : ( - {children} - ) - } - -) - const useLocation = () => { const location = React.useContext(LocationContext) + + if (location === undefined) { + throw new Error('useLocation must be used within a LocationProvider') + } + return location } -export { Location, LocationProvider, LocationContext, useLocation } +export { LocationProvider, LocationContext, useLocation } diff --git a/packages/router/src/named-routes.tsx b/packages/router/src/named-routes.tsx deleted file mode 100644 index 867ffb5e93c9..000000000000 --- a/packages/router/src/named-routes.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { validatePath, replaceParams } from './internal' - -// The first time the routes are loaded, iterate through them and create the named -// route functions. - -const namedRoutes: Record< - string, - (args?: Record) => string -> = {} -let namedRoutesDone = false - -export const resetNamedRoutes = () => { - namedRoutesDone = false -} - -const mapNamedRoutes = (routes: React.ReactElement[]) => { - if (namedRoutesDone) { - return namedRoutes - } - - for (const route of routes) { - const { path, name, notfound } = route.props - - // Skip the notfound route. - if (notfound) { - continue - } - - // Check for issues with the path. - validatePath(path) - - // Create the named route function for this route. - namedRoutes[name] = (args = {}) => replaceParams(path, args) - } - - // Only need to do this once. - namedRoutesDone = true - - return namedRoutes -} - -const routes = namedRoutes - -export { routes, mapNamedRoutes } diff --git a/packages/router/src/private-context.tsx b/packages/router/src/private-context.tsx new file mode 100644 index 000000000000..81e7d02daee1 --- /dev/null +++ b/packages/router/src/private-context.tsx @@ -0,0 +1,46 @@ +import React, { createContext, useCallback, useContext } from 'react' + +import { useRouterState } from './router-context' + +export interface PrivateState { + isPrivate: boolean + allowRender: (role?: string | string[]) => boolean + unauthenticated: string +} + +const PrivateContext = createContext(undefined) + +interface ProviderProps { + isPrivate: boolean + role?: string | string[] + unauthenticated: string +} +export const PrivateContextProvider: React.FC = ({ + children, + isPrivate, + role, + unauthenticated, +}) => { + const routerState = useRouterState() + const { isAuthenticated, hasRole } = routerState.useAuth() + + const allowRender = useCallback(() => { + return isAuthenticated && (!role || hasRole(role)) + }, [isAuthenticated, role, hasRole]) + + return ( + + {children} + + ) +} + +export const usePrivate = () => { + const context = useContext(PrivateContext) + const allowRender = context ? context.allowRender : () => false + const unauthenticated = context ? context.unauthenticated : '' + + return { isPrivate: !!context?.isPrivate, allowRender, unauthenticated } +} diff --git a/packages/router/src/router-context.tsx b/packages/router/src/router-context.tsx new file mode 100644 index 000000000000..511ac7ea0a21 --- /dev/null +++ b/packages/router/src/router-context.tsx @@ -0,0 +1,72 @@ +import React, { useReducer, createContext, useContext } from 'react' + +import type { AuthContextInterface, useAuth } from '@redwoodjs/auth' + +import { ParamType } from './internal' + +const DEFAULT_PAGE_LOADING_DELAY = 1000 // milliseconds + +export interface RouterState { + paramTypes?: Record + pageLoadingDelay?: number + useAuth: typeof useAuth +} + +const RouterStateContext = createContext(undefined) + +export interface RouterSetContextProps { + setState: (newState: Partial) => void +} + +const RouterSetContext = createContext< + React.Dispatch> | undefined +>(undefined) + +function stateReducer(state: RouterState, newState: Partial) { + return { ...state, ...newState } +} + +export const RouterContextProvider: React.FC = ({ + useAuth = () => ({} as AuthContextInterface), + paramTypes, + pageLoadingDelay = DEFAULT_PAGE_LOADING_DELAY, + children, +}) => { + const [state, setState] = useReducer(stateReducer, { + useAuth, + paramTypes, + pageLoadingDelay, + }) + + return ( + + + {children} + + + ) +} + +export const useRouterState = () => { + const context = useContext(RouterStateContext) + + if (context === undefined) { + throw new Error( + 'useRouterState must be used within a RouterContextProvider' + ) + } + + return context +} + +export const useRouterStateSetter = () => { + const context = useContext(RouterSetContext) + + if (context === undefined) { + throw new Error( + 'useRouterStateSetter must be used within a RouterContextProvider' + ) + } + + return context +} diff --git a/packages/router/src/router.tsx b/packages/router/src/router.tsx index 2feb9277b446..5792ab791025 100644 --- a/packages/router/src/router.tsx +++ b/packages/router/src/router.tsx @@ -1,25 +1,50 @@ // The guts of the router implementation. -import React, { ReactChild, ReactElement } from 'react' - -import { useAuth as useAuthHook } from '@redwoodjs/auth' +import React, { ReactChild } from 'react' import { - Location, parseSearch, replaceParams, matchPath, - mapNamedRoutes, PageLoader, Redirect, - LocationContextType, - ParamType, + useLocation, + validatePath, + LocationProvider, } from './internal' +import { PrivateContextProvider, usePrivate } from './private-context' +import { + RouterContextProvider, + RouterState, + useRouterState, +} from './router-context' import { SplashPage } from './splash-page' +import { flattenAll, isReactElement } from './util' + +/** + * A more specific interface will be generated by + * babel-plugin-redwood-routes-auto-loader when a redwood project is built + * + * Example: + * interface AvailableRoutes { + * home: () => "/" + * test: () => "/test" + * } + */ +interface AvailableRoutes { + [key: string]: (args?: Record) => string +} + +const namedRoutes: AvailableRoutes = {} + +type PageType = + | Spec + | React.ComponentType + | ((props: any) => JSX.Element) interface RouteProps { - page: Spec | React.ComponentType | ((props: any) => JSX.Element) path?: string + page?: PageType name?: string notfound?: boolean redirect?: string @@ -27,8 +52,78 @@ interface RouteProps { whileLoading?: () => ReactChild | null } -const Route: React.VFC = () => { - return null +const Route: React.VFC = ({ + path, + page, + name, + redirect, + notfound, + whileLoading, +}) => { + const location = useLocation() + const routerState = useRouterState() + const { isPrivate, allowRender, unauthenticated } = usePrivate() + + if (notfound) { + // TODO: How should we handle `notfound`? + return null + } + + if (!path) { + throw new Error(`Route "${name}" needs to specify a path`) + } + + // Check for issues with the path. + validatePath(path) + + const { match, params: pathParams } = matchPath( + path, + location.pathname, + routerState.paramTypes + ) + + if (!match) { + return null + } + + if (isPrivate) { + const { loading } = routerState.useAuth() + + if (loading) { + return <>{whileLoading?.() || null} + } + + if (!allowRender()) { + const currentLocation = + global.location.pathname + encodeURIComponent(global.location.search) + + return ( + + ) + } + } + + const searchParams = parseSearch(location.search) + const allParams = { ...pathParams, ...searchParams } + + if (redirect) { + const newPath = replaceParams(redirect, allParams) + return + } + + if (!page || !name) { + return null + } + + return ( + + ) } interface PrivateProps { @@ -42,57 +137,124 @@ interface PrivateProps { * When a user is not authenticated and attempts to visit this route they will be * redirected to `unauthenticated` route. */ -const Private: React.FC = () => { - return null +const Private: React.FC = ({ + children, + unauthenticated, + role, +}) => { + return ( + + {children} + + ) } -interface PrivatePageLoaderProps { - useAuth: typeof useAuthHook - unauthenticatedRoute: (args?: Record) => string - role: string | string[] - whileLoading?: () => ReactElement | null +function isRoute( + node: React.ReactNode +): node is React.ReactElement { + return isReactElement(node) && node.type === Route } -const PrivatePageLoader: React.FC = ({ +interface RouterProps extends RouterState {} + +const Router: React.FC = ({ useAuth, - unauthenticatedRoute, - role, - whileLoading = () => null, + paramTypes, + pageLoadingDelay, children, }) => { - const { loading, isAuthenticated, hasRole } = useAuth() + const flatChildArray = flattenAll(children) + const shouldShowSplash = + flatChildArray.length === 1 && + isReactElement(flatChildArray[0]) && + flatChildArray[0].props?.notfound - if (loading) { - return whileLoading() + if (shouldShowSplash) { + return } - if ( - (isAuthenticated && !role) || - (isAuthenticated && role && hasRole(role)) - ) { - return <>{children || null} - } + flatChildArray.forEach((child) => { + if (isRoute(child)) { + const { name, path } = child.props + + if (path) { + // Check for issues with the path. + validatePath(path) + + if (name && path) { + namedRoutes[name] = (args = {}) => replaceParams(path, args) + } + } + } + }) - const currentLocation = - window.location.pathname + encodeURIComponent(window.location.search) return ( - + + + + <>{children} + + + ) } -interface RouterProps { - useAuth?: typeof useAuthHook - paramTypes?: Record - pageLoadingDelay?: number -} +const NotFoundChecker: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const location = useLocation() + const routerState = useRouterState() + + let foundMatchingRoute = false + let NotFoundPage: + | React.ComponentType + | ((props: any) => JSX.Element) + | undefined = undefined + const flatChildArray = flattenAll(children) + + flatChildArray.forEach((child) => { + if (isRoute(child)) { + const { path } = child.props + + if (path) { + const { match } = matchPath( + path, + location.pathname, + routerState.paramTypes + ) -const Router: React.FC = (props) => ( - - {(locationContext: LocationContextType) => ( - - )} - -) + if (match) { + foundMatchingRoute = true + } + } + + if (child.props.notfound && child.props.page) { + if (!isSpec(child.props.page)) { + NotFoundPage = child.props.page + } + } + } + }) + + if (!foundMatchingRoute && NotFoundPage) { + return ( + + ) + } + + return <>{children} +} function isSpec(specOrPage: Spec | React.ComponentType): specOrPage is Spec { return (specOrPage as Spec).loader !== undefined @@ -117,7 +279,8 @@ export interface Spec { * import WhateverPage from 'src/pages/WhateverPage' * * Before passing a "page" to the PageLoader, we will normalize the manually - * imported version into a spec. */ + * imported version into a spec. + */ const normalizePage = (specOrPage: Spec | React.ComponentType): Spec => { if (isSpec(specOrPage)) { // Already a spec, just return it. @@ -132,156 +295,4 @@ const normalizePage = (specOrPage: Spec | React.ComponentType): Spec => { } } -const DEFAULT_PAGE_LOADING_DELAY = 1000 // milliseconds - -interface LoadersProps { - allParams: Record - Page: Spec | React.ComponentType - pageLoadingDelay: number -} - -const Loaders: React.VFC = ({ - allParams, - Page, - pageLoadingDelay, -}) => { - return ( - - ) -} - -function isReactElement( - element: Exclude -): element is ReactElement { - return (element as ReactElement).type !== undefined -} - -interface RouterImplProps { - pathname: string - search?: string - hash?: string -} - -const RouterImpl: React.FC = ({ - pathname, - search, - paramTypes, - pageLoadingDelay = DEFAULT_PAGE_LOADING_DELAY, - children, - useAuth = useAuthHook, -}) => { - const routes = React.useMemo(() => { - // Find `Private` components, mark their children `Route` components as private, - // and merge them into a single array. - const privateRoutes = - React.Children.toArray(children) - .filter(isReactElement) - .filter((child) => child.type === Private) - .map((privateElement) => { - // Set `Route` props - const { unauthenticated, role, children } = privateElement.props - return ( - React.Children.toArray(children) - // Make sure only valid routes are considered - .filter(isReactElement) - .filter((route) => route.type === Route) - .map((route) => - React.cloneElement(route, { - private: true, - unauthenticatedRedirect: unauthenticated, - role: role, - }) - ) - ) - }) - .reduce((a, b) => a.concat(b), []) || [] - - const routes = [ - ...privateRoutes, - ...React.Children.toArray(children) - .filter(isReactElement) - .filter((child) => child.type === Route), - ] - - return routes - }, [children]) - - const namedRoutes = React.useMemo(() => mapNamedRoutes(routes), [routes]) - - let NotFoundPage - - for (const route of routes) { - const { - path, - page: Page, - redirect, - notfound, - private: privateRoute, - unauthenticatedRedirect, - whileLoading, - } = route.props - - if (notfound) { - NotFoundPage = Page - continue - } - - const { match, params: pathParams } = matchPath(path, pathname, paramTypes) - - if (match) { - const searchParams = parseSearch(search) - const allParams = { ...pathParams, ...searchParams } - - if (redirect) { - const newPath = replaceParams(redirect, pathParams) - return - } else { - if (privateRoute) { - if (typeof useAuth === 'undefined') { - throw new Error( - "You're using a private route, but `useAuth` is undefined. " + - 'Have you created an AuthProvider, or passed in the ' + - 'incorrect prop to `useAuth`?' - ) - } - - return ( - - - - ) - } - - return ( - - ) - } - } - } - - // If only the notfound page is specified, show the Redwood splash page. - if (routes.length === 1 && NotFoundPage) { - return - } - - return -} - -export { Router, Route, Private } +export { Router, Route, Private, namedRoutes as routes, isRoute } diff --git a/packages/router/src/util.ts b/packages/router/src/util.ts index fa517078cb36..7625f59d7a2f 100644 --- a/packages/router/src/util.ts +++ b/packages/router/src/util.ts @@ -1,4 +1,4 @@ -import React from 'react' +import React, { Children, ReactElement, ReactNode } from 'react' /** Create a React Context with the given name. */ const createNamedContext = ( @@ -219,10 +219,34 @@ const replaceParams = (path: string, args: Record = {}) => { return newPath } +function isReactElement(node: ReactNode): node is ReactElement { + return ( + node !== undefined && + node !== null && + (node as ReactElement).type !== undefined + ) +} + +function flattenAll(children: ReactNode): ReactNode[] { + const childrenArray = Children.toArray(children) + + return childrenArray.flatMap((child) => { + if (isReactElement(child) && child.props.children) { + return [child, ...flattenAll(child.props.children)] + } + + return [child] + }) +} + +// TODO: Write function visitChildren + export { createNamedContext, matchPath, parseSearch, validatePath, replaceParams, + isReactElement, + flattenAll, }