From 30f3ecb182bca12bc17329dd6c73d8cce40e4db2 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Mon, 15 Apr 2024 15:57:19 +0200 Subject: [PATCH] chore(refactor): Split the router out into smaller logical units (#10434) --- packages/router/src/Route.tsx | 46 ++ .../src/__tests__/pageLoadingContext.test.tsx | 551 ++++++++++++++++++ packages/router/src/__tests__/router.test.tsx | 420 +------------ packages/router/src/active-route-loader.tsx | 2 +- packages/router/src/page.ts | 52 ++ packages/router/src/route-validators.tsx | 36 +- packages/router/src/router.tsx | 36 +- packages/router/src/util.ts | 50 +- 8 files changed, 669 insertions(+), 524 deletions(-) create mode 100644 packages/router/src/Route.tsx create mode 100644 packages/router/src/__tests__/pageLoadingContext.test.tsx create mode 100644 packages/router/src/page.ts diff --git a/packages/router/src/Route.tsx b/packages/router/src/Route.tsx new file mode 100644 index 000000000000..12a7b852cc20 --- /dev/null +++ b/packages/router/src/Route.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import type { ReactElement } from 'react' + +import type { PageType } from './page' + +export type RenderMode = 'stream' | 'html' + +export interface RedirectRouteProps { + redirect: string + path: string + name?: string +} + +export interface NotFoundRouteProps { + notfound: boolean + page: PageType + prerender?: boolean + renderMode?: RenderMode +} + +export interface RouteProps { + path: string + page: PageType + name: string + prerender?: boolean + renderMode?: RenderMode + whileLoadingPage?: () => ReactElement | null +} + +export type InternalRouteProps = Partial< + RouteProps & RedirectRouteProps & NotFoundRouteProps +> + +/** + * Route is now a "virtual" component + * it is actually never rendered. All the page loading logic happens in active-route-loader + * and all the validation happens within utility functions called from the Router + */ +export function Route(props: RouteProps): JSX.Element +export function Route(props: RedirectRouteProps): JSX.Element +export function Route(props: NotFoundRouteProps): JSX.Element +export function Route( + _props: RouteProps | RedirectRouteProps | NotFoundRouteProps, +) { + return <> +} diff --git a/packages/router/src/__tests__/pageLoadingContext.test.tsx b/packages/router/src/__tests__/pageLoadingContext.test.tsx new file mode 100644 index 000000000000..eb4088fd20ea --- /dev/null +++ b/packages/router/src/__tests__/pageLoadingContext.test.tsx @@ -0,0 +1,551 @@ +let mockDelay = 0 +jest.mock('../page', () => { + const actualUtil = jest.requireActual('../util') + const { lazy } = jest.requireActual('react') + + return { + ...actualUtil, + normalizePage: (specOrPage: Spec | React.ComponentType) => ({ + name: specOrPage.name, + prerenderLoader: () => ({ default: specOrPage }), + LazyComponent: lazy( + () => + new Promise((resolve) => + setTimeout(() => resolve({ default: specOrPage }), mockDelay), + ), + ), + }), + } +}) + +import React, { useEffect, useState } from 'react' + +import '@testing-library/jest-dom' +import { act, configure, render, waitFor } from '@testing-library/react' + +import type { AuthContextInterface } from '@redwoodjs/auth' + +import { + navigate, + Private, + PrivateSet, + Redirect, + Route, + Router, + Set, + useParams, +} from '..' +import { useLocation } from '../location' +import type { Spec } from '../page' +import { usePageLoadingContext } from '../PageLoadingContext' + +// Running into intermittent test timeout behavior in +// https://github.com/redwoodjs/redwood/pull/4992 +// Attempting to work around by bumping the default timeout of 5000 +const timeoutForFlakeyAsyncTests = 8000 + +configure({ + asyncUtilTimeout: 5_000, +}) + +beforeEach(() => { + window.history.pushState({}, '', '/') +}) + +type UnknownAuthContextInterface = AuthContextInterface< + unknown, + unknown, + unknown, + unknown, + unknown, + unknown, + unknown, + unknown, + unknown, + unknown, + unknown, + unknown +> + +function createDummyAuthContextValues( + partial: Partial, +) { + const authContextValues: UnknownAuthContextInterface = { + loading: true, + isAuthenticated: false, + userMetadata: null, + currentUser: null, + logIn: async () => null, + logOut: async () => null, + signUp: async () => null, + getToken: async () => null, + getCurrentUser: async () => null, + hasRole: () => false, + reauthenticate: async () => {}, + client: null, + type: 'custom', + hasError: false, + forgotPassword: async () => null, + resetPassword: async () => null, + validateResetToken: async () => null, + } + + return { ...authContextValues, ...partial } +} + +interface MockAuth { + isAuthenticated?: boolean + loading?: boolean + hasRole?: boolean | ((role: string[]) => boolean) + loadingTimeMs?: number +} + +const mockUseAuth = + ( + { + isAuthenticated = false, + loading = false, + hasRole = false, + loadingTimeMs, + }: MockAuth = { + isAuthenticated: false, + loading: false, + hasRole: false, + }, + ) => + () => { + const [authLoading, setAuthLoading] = useState(loading) + const [authIsAuthenticated, setAuthIsAuthenticated] = + useState(isAuthenticated) + + useEffect(() => { + let timer: NodeJS.Timeout | undefined + if (loadingTimeMs) { + timer = setTimeout(() => { + setAuthLoading(false) + setAuthIsAuthenticated(true) + }, loadingTimeMs) + } + return () => { + if (timer) { + clearTimeout(timer) + } + } + }, []) + + return createDummyAuthContextValues({ + loading: authLoading, + isAuthenticated: authIsAuthenticated, + hasRole: typeof hasRole === 'boolean' ? () => hasRole : hasRole, + }) + } + +interface LayoutProps { + children: React.ReactNode +} + +const HomePage = () =>

Home Page

+const LoginPage = () =>

Login Page

+const AboutPage = () =>

About Page

+const PrivatePage = () =>

Private Page

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

404

+const ParamPage = ({ value, q }: { value: string; q: string }) => { + const params = useParams() + + return ( +
+

param {`${value}${q}`}

+

hook params {`${params.value}?${params.q}`}

+
+ ) +} +const LocationPage = () => { + const location = useLocation() + + return ( + <> +

Location Page

+

{location.pathname}

+ + ) +} + +const HomePagePlaceholder = () => <>HomePagePlaceholder +const AboutPagePlaceholder = () => <>AboutPagePlaceholder +const ParamPagePlaceholder = () => <>ParamPagePlaceholder +const RedirectPagePlaceholder = () => <>RedirectPagePlaceholder +const PrivatePagePlaceholder = () => <>PrivatePagePlaceholder +const LoginPagePlaceholder = () => <>LoginPagePlaceholder + +const PageLoadingContextLayout = ({ children }: LayoutProps) => { + const { loading } = usePageLoadingContext() + + return ( + <> +

Page Loading Context Layout

+ {loading &&

loading in layout...

} + {!loading &&

done loading in layout

} + {children} + + ) +} + +const PageLoadingContextPage = () => { + const { loading } = usePageLoadingContext() + + return ( + <> +

Page Loading Context Page

+ {loading &&

loading in page...

} + {!loading &&

done loading in page

} + + ) +} + +const TestRouter = ({ + authenticated, + hasRole, +}: { + authenticated?: boolean + hasRole?: boolean +}) => ( + + + + + + + + + + + + + {/* Keeping this one around for now, so we don't accidentally break + Private until we're ready to remove it */} + + + + + + + + + + +) + +beforeEach(() => { + // One of the tests modifies this, so we need to reset it before each test + mockDelay = 400 +}) + +afterEach(() => { + mockDelay = 0 +}) + +test( + 'Basic home page', + async () => { + const screen = render() + + await waitFor(() => screen.getByText('HomePagePlaceholder')) + await waitFor(() => screen.getByText('Home Page')) + }, + timeoutForFlakeyAsyncTests, +) + +test( + 'Navigation', + async () => { + const screen = render() + // TODO: implement pageLoadDelay (potentially not needed with preloading + // features) + // Above TODO added in https://github.com/redwoodjs/redwood/pull/8392 + // First we should render an empty page while waiting for pageLoadDelay to + // pass + // expect(screen.container).toBeEmptyDOMElement() + + // Then we should render whileLoadingPage + await waitFor(() => screen.getByText('HomePagePlaceholder')) + + // Finally we should render the actual page + await waitFor(() => screen.getByText('Home Page')) + + act(() => navigate('/about')) + + // Now after navigating we should keep rendering the previous page until + // the new page has loaded, or until pageLoadDelay has passed. This + // ensures we don't show a "white flash", i.e. render an empty page, while + // navigating the page + expect(screen.container).not.toBeEmptyDOMElement() + await waitFor(() => screen.getByText('Home Page')) + expect(screen.container).not.toBeEmptyDOMElement() + + // As for HomePage we first render the placeholder... + await waitFor(() => screen.getByText('AboutPagePlaceholder')) + // ...and then the actual page + await waitFor(() => screen.getByText('About Page')) + }, + timeoutForFlakeyAsyncTests, +) + +test( + 'Redirect page', + async () => { + act(() => navigate('/redirect')) + const screen = render() + await waitFor(() => screen.getByText('RedirectPagePlaceholder')) + await waitFor(() => screen.getByText('About Page')) + }, + timeoutForFlakeyAsyncTests, +) + +test( + 'Redirect route', + async () => { + const screen = render() + await waitFor(() => screen.getByText('HomePagePlaceholder')) + await waitFor(() => screen.getByText('Home Page')) + act(() => navigate('/redirect2/redirected?q=-cue')) + await waitFor(() => screen.getByText('ParamPagePlaceholder')) + await waitFor(() => screen.getByText('param redirected-cue')) + }, + timeoutForFlakeyAsyncTests, +) + +test( + 'Private page when not authenticated', + async () => { + act(() => navigate('/private')) + const screen = render() + await waitFor(() => { + expect( + screen.queryByText('PrivatePagePlaceholder'), + ).not.toBeInTheDocument() + expect(screen.queryByText('Private Page')).not.toBeInTheDocument() + expect(screen.queryByText('LoginPagePlaceholder')).toBeInTheDocument() + }) + await waitFor(() => screen.getByText('Login Page')) + }, + timeoutForFlakeyAsyncTests, +) + +test( + 'Private page when authenticated', + async () => { + act(() => navigate('/private')) + const screen = render() + + await waitFor(() => screen.getByText('PrivatePagePlaceholder')) + await waitFor(() => screen.getByText('Private Page')) + await waitFor(() => { + expect(screen.queryByText('Login Page')).not.toBeInTheDocument() + }) + }, + timeoutForFlakeyAsyncTests, +) + +test( + 'Private page when authenticated but does not have the role', + async () => { + act(() => navigate('/private_with_role')) + const screen = render() + + await waitFor(() => { + expect( + screen.queryByText('PrivatePagePlaceholder'), + ).not.toBeInTheDocument() + expect(screen.queryByText('Private Page')).not.toBeInTheDocument() + expect(screen.queryByText('LoginPagePlaceholder')).toBeInTheDocument() + }) + await waitFor(() => screen.getByText('Login Page')) + }, + timeoutForFlakeyAsyncTests, +) + +test( + 'Private page when authenticated but does have the role', + async () => { + act(() => navigate('/private_with_role')) + const screen = render() + + await waitFor(() => { + expect( + screen.queryByText('PrivatePagePlaceholder'), + ).not.toBeInTheDocument() + expect(screen.queryByText('Private Page')).toBeInTheDocument() + }) + }, + timeoutForFlakeyAsyncTests, +) + +test( + 'useLocation', + async () => { + act(() => navigate('/location')) + const screen = render() + await waitFor(() => screen.getByText('Location Page')) + await waitFor(() => screen.getByText('/location')) + + act(() => navigate('/about')) + // After navigating we will keep rendering the previous page for 100 ms, + // (which is our configured delay) before rendering the "whileLoading" + // page. + // TODO: We don't currently implement page loading delay anymore as of + // https://github.com/redwoodjs/redwood/pull/8392. See if we should add + // that back in. + // await waitFor(() => screen.getByText('Location Page')) + + // Because we're still rendering the LocationPage, the pathname returned + // by useLocation should still be /location + // But because of a limitation in our implementation, that's currently + // not the case. + // TODO: Update this test when #3779 is fixed. (It'll start failing). + // Should get rid of the waitFor below and use the one that's currently + // commented out. (Test disabled as of + // https://github.com/redwoodjs/redwood/pull/8392) + // await waitFor(() => screen.getByText('/about')) + // // await waitFor(() => screen.getByText('/location')) + + // And then we'll render the placeholder... + await waitFor(() => screen.getByText('AboutPagePlaceholder')) + // ...followed by the actual page + await waitFor(() => screen.getByText('About Page')) + }, + timeoutForFlakeyAsyncTests, +) + +test( + 'path params should never be empty', + async () => { + const PathParamPage = ({ value }: { value: string }) => { + expect(value).not.toBeFalsy() + return

{value}

+ } + + const TestRouter = () => ( + + + + + ) + + act(() => navigate('/path-param-test/test_value')) + const screen = render() + + // First we render the path parameter value "test_value" + await waitFor(() => screen.getByText('test_value')) + + act(() => navigate('/about')) + // After navigating we should keep displaying the old path value... + await waitFor(() => screen.getByText('test_value')) + // ...until we switch over to render the about page loading component... + await waitFor(() => screen.getByText('AboutPagePlaceholder')) + // ...followed by the actual page + await waitFor(() => screen.getByText('About Page')) + }, + timeoutForFlakeyAsyncTests, +) + +test( + 'usePageLoadingContext', + async () => { + // We want to show a loading indicator if loading pages is taking a long + // time. But at the same time we don't want to show it right away, because + // then there'll be a flash of the loading indicator on every page load. + // So we have a `pageLoadingDelay` delay to control how long it waits + // before showing the loading state (default is 1000 ms). + // + // RW lazy loads pages by default, that's why it could potentially take a + // while to load a page. But during tests we don't do that. So we have to + // fake a delay. That's what `mockDelay` is for. `mockDelay` has to be + // longer than `pageLoadingDelay`, but not too long so the test takes + // longer than it has to, and also not too long so the entire test times + // out. + + // Had to increase this to make the test pass on Windows + mockDelay = 700 + + // sets pageLoadingDelay={200}. (Default is 1000.) + const screen = render() + + act(() => navigate('/page-loading-context')) + + // 'Page Loading Context Layout' should always be shown + await waitFor(() => screen.getByText('Page Loading Context Layout')) + + // 'loading in layout...' should only be shown while the page is loading. + // So in this case, for the first 700ms + await waitFor(() => screen.getByText('loading in layout...')) + + // After 700ms 'Page Loading Context Page' should be rendered + await waitFor(() => screen.getByText('Page Loading Context Page')) + + // This shouldn't show up, because the page shouldn't render before it's + // fully loaded + expect(screen.queryByText('loading in page...')).not.toBeInTheDocument() + + await waitFor(() => screen.getByText('done loading in page')) + await waitFor(() => screen.getByText('done loading in layout')) + }, + timeoutForFlakeyAsyncTests, +) diff --git a/packages/router/src/__tests__/router.test.tsx b/packages/router/src/__tests__/router.test.tsx index 3e6344188f67..40027a6f16fb 100644 --- a/packages/router/src/__tests__/router.test.tsx +++ b/packages/router/src/__tests__/router.test.tsx @@ -1,33 +1,7 @@ -let mockDelay = 0 -jest.mock('../util', () => { - const actualUtil = jest.requireActual('../util') - const { lazy } = jest.requireActual('react') - - return { - ...actualUtil, - normalizePage: (specOrPage: Spec | React.ComponentType) => ({ - name: specOrPage.name, - prerenderLoader: () => ({ default: specOrPage }), - LazyComponent: lazy( - () => - new Promise((resolve) => - setTimeout(() => resolve({ default: specOrPage }), mockDelay), - ), - ), - }), - } -}) - import React, { useEffect, useState } from 'react' import '@testing-library/jest-dom/jest-globals' -import { - act, - configure, - fireEvent, - render, - waitFor, -} from '@testing-library/react' +import { act, fireEvent, render, waitFor } from '@testing-library/react' import type { AuthContextInterface, UseAuth } from '@redwoodjs/auth' @@ -41,16 +15,10 @@ import { Redirect, Route, Router, - usePageLoadingContext, -} from '../' -import { useLocation } from '../location' +} from '../index' import { useParams } from '../params' import { Set } from '../Set' -import type { GeneratedRoutesMap, Spec } from '../util' - -/** running into intermittent test timeout behavior in https://github.com/redwoodjs/redwood/pull/4992 - attempting to work around by bumping the default timeout of 5000 */ -const timeoutForFlakeyAsyncTests = 8000 +import type { GeneratedRoutesMap } from '../util' type UnknownAuthContextInterface = AuthContextInterface< unknown, @@ -164,393 +132,11 @@ const ParamPage = ({ value, q }: { value: string; q: string }) => { ) } -configure({ - asyncUtilTimeout: 5_000, -}) - beforeEach(() => { window.history.pushState({}, '', '/') Object.keys(routes).forEach((key) => delete routes[key]) }) -describe('slow imports', () => { - const HomePagePlaceholder = () => <>HomePagePlaceholder - const AboutPagePlaceholder = () => <>AboutPagePlaceholder - const ParamPagePlaceholder = () => <>ParamPagePlaceholder - const RedirectPagePlaceholder = () => <>RedirectPagePlaceholder - const PrivatePagePlaceholder = () => <>PrivatePagePlaceholder - const LoginPagePlaceholder = () => <>LoginPagePlaceholder - - const LocationPage = () => { - const location = useLocation() - - return ( - <> -

Location Page

-

{location.pathname}

- - ) - } - - const PageLoadingContextLayout = ({ children }: LayoutProps) => { - const { loading } = usePageLoadingContext() - - return ( - <> -

Page Loading Context Layout

- {loading &&

loading in layout...

} - {!loading &&

done loading in layout

} - {children} - - ) - } - - const PageLoadingContextPage = () => { - const { loading } = usePageLoadingContext() - - return ( - <> -

Page Loading Context Page

- {loading &&

loading in page...

} - {!loading &&

done loading in page

} - - ) - } - - const TestRouter = ({ - authenticated, - hasRole, - }: { - authenticated?: boolean - hasRole?: boolean - }) => ( - - - - - - - - - - - - - {/* Keeping this one around for now, so we don't accidentally break - Private until we're ready to remove it */} - - - - - - - - - - - ) - - beforeEach(() => { - // One of the tests modifies this, so we need to reset it before each test - mockDelay = 400 - }) - - afterEach(() => { - mockDelay = 0 - }) - - test( - 'Basic home page', - async () => { - const screen = render() - - await waitFor(() => screen.getByText('HomePagePlaceholder')) - await waitFor(() => screen.getByText('Home Page')) - }, - timeoutForFlakeyAsyncTests, - ) - - test( - 'Navigation', - async () => { - const screen = render() - // First we should render an empty page while waiting for pageLoadDelay to - // pass - - //TODO: implement pageLoadDelay potentially don't need with preloading features - // expect(screen.container).toBeEmptyDOMElement() - - // Then we should render whileLoadingPage - await waitFor(() => screen.getByText('HomePagePlaceholder')) - - // Finally we should render the actual page - await waitFor(() => screen.getByText('Home Page')) - - act(() => navigate('/about')) - - // Now after navigating we should keep rendering the previous page until - // the new page has loaded, or until pageLoadDelay has passed. This - // ensures we don't show a "white flash", i.e. render an empty page, while - // navigating the page - expect(screen.container).not.toBeEmptyDOMElement() - await waitFor(() => screen.getByText('Home Page')) - expect(screen.container).not.toBeEmptyDOMElement() - - // As for HomePage we first render the placeholder... - await waitFor(() => screen.getByText('AboutPagePlaceholder')) - // ...and then the actual page - await waitFor(() => screen.getByText('About Page')) - }, - timeoutForFlakeyAsyncTests, - ) - - test( - 'Redirect page', - async () => { - act(() => navigate('/redirect')) - const screen = render() - await waitFor(() => screen.getByText('RedirectPagePlaceholder')) - await waitFor(() => screen.getByText('About Page')) - }, - timeoutForFlakeyAsyncTests, - ) - - test( - 'Redirect route', - async () => { - const screen = render() - await waitFor(() => screen.getByText('HomePagePlaceholder')) - await waitFor(() => screen.getByText('Home Page')) - act(() => navigate('/redirect2/redirected?q=cue')) - await waitFor(() => screen.getByText('ParamPagePlaceholder')) - await waitFor(() => screen.getByText('param redirectedcue')) - }, - timeoutForFlakeyAsyncTests, - ) - - test( - 'Private page when not authenticated', - async () => { - act(() => navigate('/private')) - const screen = render() - await waitFor(() => { - expect( - screen.queryByText('PrivatePagePlaceholder'), - ).not.toBeInTheDocument() - expect(screen.queryByText('Private Page')).not.toBeInTheDocument() - expect(screen.queryByText('LoginPagePlaceholder')).toBeInTheDocument() - }) - await waitFor(() => screen.getByText('Login Page')) - }, - timeoutForFlakeyAsyncTests, - ) - - test( - 'Private page when authenticated', - async () => { - act(() => navigate('/private')) - const screen = render() - - await waitFor(() => screen.getByText('PrivatePagePlaceholder')) - await waitFor(() => screen.getByText('Private Page')) - await waitFor(() => { - expect(screen.queryByText('Login Page')).not.toBeInTheDocument() - }) - }, - timeoutForFlakeyAsyncTests, - ) - - test( - 'Private page when authenticated but does not have the role', - async () => { - act(() => navigate('/private_with_role')) - const screen = render() - - await waitFor(() => { - expect( - screen.queryByText('PrivatePagePlaceholder'), - ).not.toBeInTheDocument() - expect(screen.queryByText('Private Page')).not.toBeInTheDocument() - expect(screen.queryByText('LoginPagePlaceholder')).toBeInTheDocument() - }) - await waitFor(() => screen.getByText('Login Page')) - }, - timeoutForFlakeyAsyncTests, - ) - - test( - 'Private page when authenticated but does have the role', - async () => { - act(() => navigate('/private_with_role')) - const screen = render() - - await waitFor(() => { - expect( - screen.queryByText('PrivatePagePlaceholder'), - ).not.toBeInTheDocument() - expect(screen.queryByText('Private Page')).toBeInTheDocument() - }) - }, - timeoutForFlakeyAsyncTests, - ) - - test( - 'useLocation', - async () => { - act(() => navigate('/location')) - const screen = render() - await waitFor(() => screen.getByText('Location Page')) - await waitFor(() => screen.getByText('/location')) - - act(() => navigate('/about')) - // After navigating we will keep rendering the previous page for 100 ms, - // (which is our configured delay) before rendering the "whileLoading" - // page. - // TODO: We don't currently implement page loading delay anymore - // await waitFor(() => screen.getByText('Location Page')) - - // And then we'll render the placeholder... - await waitFor(() => screen.getByText('AboutPagePlaceholder')) - // ...followed by the actual page - await waitFor(() => screen.getByText('About Page')) - }, - timeoutForFlakeyAsyncTests, - ) - - test( - 'path params should never be empty', - async () => { - const PathParamPage = ({ value }: { value: string }) => { - expect(value).not.toBeFalsy() - return

{value}

- } - - const TestRouter = () => ( - - - - - ) - - act(() => navigate('/path-param-test/test_value')) - const screen = render() - - // First we render the path parameter value "test_value" - await waitFor(() => screen.getByText('test_value')) - - act(() => navigate('/about')) - // After navigating we should keep displaying the old path value... - await waitFor(() => screen.getByText('test_value')) - // ...until we switch over to render the about page loading component... - await waitFor(() => screen.getByText('AboutPagePlaceholder')) - // ...followed by the actual page - await waitFor(() => screen.getByText('About Page')) - }, - timeoutForFlakeyAsyncTests, - ) - - test( - 'usePageLoadingContext', - async () => { - // We want to show a loading indicator if loading pages is taking a long - // time. But at the same time we don't want to show it right away, because - // then there'll be a flash of the loading indicator on every page load. - // So we have a `pageLoadingDelay` delay to control how long it waits - // before showing the loading state (default is 1000 ms). - // - // RW lazy loads pages by default, that's why it could potentially take a - // while to load a page. But during tests we don't do that. So we have to - // fake a delay. That's what `mockDelay` is for. `mockDelay` has to be - // longer than `pageLoadingDelay`, but not too long so the test takes - // longer than it has to, and also not too long so the entire test times - // out. - - // Had to increase this to make the test pass on Windows - mockDelay = 700 - - // sets pageLoadingDelay={200}. (Default is 1000.) - const screen = render() - - act(() => navigate('/page-loading-context')) - - // 'Page Loading Context Layout' should always be shown - await waitFor(() => screen.getByText('Page Loading Context Layout')) - - // 'loading in layout...' should only be shown while the page is loading. - // So in this case, for the first 700ms - await waitFor(() => screen.getByText('loading in layout...')) - - // After 700ms 'Page Loading Context Page' should be rendered - await waitFor(() => screen.getByText('Page Loading Context Page')) - - // This shouldn't show up, because the page shouldn't render before it's - // fully loaded - expect(screen.queryByText('loading in page...')).not.toBeInTheDocument() - - await waitFor(() => screen.getByText('done loading in page')) - await waitFor(() => screen.getByText('done loading in layout')) - }, - timeoutForFlakeyAsyncTests, - ) -}) - describe('inits routes and navigates as expected', () => { const TestRouter = () => ( diff --git a/packages/router/src/active-route-loader.tsx b/packages/router/src/active-route-loader.tsx index 00ac7d47102e..fa8455f60b0b 100644 --- a/packages/router/src/active-route-loader.tsx +++ b/packages/router/src/active-route-loader.tsx @@ -1,8 +1,8 @@ import React, { Suspense, useEffect, useRef } from 'react' import { getAnnouncement, getFocus, resetFocus } from './a11yUtils' +import type { Spec } from './page' import { usePageLoadingContext } from './PageLoadingContext' -import type { Spec } from './util' import { inIframe } from './util' interface Props { diff --git a/packages/router/src/page.ts b/packages/router/src/page.ts new file mode 100644 index 000000000000..b858063fdec2 --- /dev/null +++ b/packages/router/src/page.ts @@ -0,0 +1,52 @@ +export interface Spec { + name: string + prerenderLoader: (name?: string) => { default: React.ComponentType } + LazyComponent: + | React.LazyExoticComponent> + | React.ComponentType +} + +export function isSpec( + specOrPage: Spec | React.ComponentType, +): specOrPage is Spec { + return (specOrPage as Spec).LazyComponent !== undefined +} + +/** + * Pages can be imported automatically or manually. Automatic imports are actually + * objects and take the following form (which we call a 'spec'): + * + * const WhateverPage = { + * name: 'WhateverPage', + * LazyComponent: lazy(() => import('src/pages/WhateverPage')) + * prerenderLoader: ... + * } + * + * Manual imports simply load the page: + * + * import WhateverPage from 'src/pages/WhateverPage' + * + * Before passing a "page" to the PageLoader, we will normalize the manually + * imported version into a spec. + */ +export function normalizePage( + specOrPage: Spec | React.ComponentType, +): Spec { + if (isSpec(specOrPage)) { + // Already a spec, just return it. + return specOrPage + } + + // Wrap the Page in a fresh spec, and put it in a promise to emulate + // an async module import. + return { + name: specOrPage.name, + prerenderLoader: () => ({ default: specOrPage }), + LazyComponent: specOrPage, + } +} + +export type PageType = + | Spec + | React.ComponentType + | ((props: any) => JSX.Element) diff --git a/packages/router/src/route-validators.tsx b/packages/router/src/route-validators.tsx index 99b4aacd0774..b6e084d46c17 100644 --- a/packages/router/src/route-validators.tsx +++ b/packages/router/src/route-validators.tsx @@ -1,33 +1,13 @@ import type { ReactNode, ReactElement } from 'react' import { isValidElement } from 'react' -import type { RouteProps } from './router' -import { Route } from './router' -import type { Spec } from './util' - -export type RenderMode = 'stream' | 'html' - -export type PageType = - | Spec - | React.ComponentType - | ((props: any) => JSX.Element) - -export interface RedirectRouteProps { - redirect: string - path: string - name?: string -} - -export interface NotFoundRouteProps { - notfound: boolean - page: PageType - prerender?: boolean - renderMode?: RenderMode -} - -export type InternalRouteProps = Partial< - RouteProps & RedirectRouteProps & NotFoundRouteProps -> +import type { + InternalRouteProps, + NotFoundRouteProps, + RedirectRouteProps, + RouteProps, +} from './Route' +import { Route } from './Route' const isNodeTypeRoute = ( node: ReactNode, @@ -77,7 +57,7 @@ export function isNotFoundRoute( /** * Check that the Route element is ok * and that it could be one of the following: - * (ridirect Route) + * (redirect Route) * (notfound Route) * (standard Route) * diff --git a/packages/router/src/router.tsx b/packages/router/src/router.tsx index 8b358cf14532..9aa184554c6a 100644 --- a/packages/router/src/router.tsx +++ b/packages/router/src/router.tsx @@ -1,25 +1,23 @@ -import type { ReactNode, ReactElement } from 'react' +import type { ReactNode } from 'react' import React, { useMemo, memo } from 'react' import { ActiveRouteLoader } from './active-route-loader' import { AuthenticatedRoute } from './AuthenticatedRoute' import { LocationProvider, useLocation } from './location' +import { normalizePage } from './page' +import type { PageType } from './page' import { PageLoadingContextProvider } from './PageLoadingContext' import { ParamsProvider } from './params' import { Redirect } from './redirect' -import type { - NotFoundRouteProps, - RedirectRouteProps, - RenderMode, -} from './route-validators' -import { isValidRoute, PageType } from './route-validators' +import { Route } from './Route' +import type { RouteProps } from './Route' +import { isValidRoute } from './route-validators' import type { RouterContextProviderProps } from './router-context' import { RouterContextProvider } from './router-context' import { SplashPage } from './splash-page' import { analyzeRoutes, matchPath, - normalizePage, parseSearch, replaceParams, validatePath, @@ -34,27 +32,6 @@ import type { AvailableRoutes } from './index' // projects const namedRoutes: AvailableRoutes = {} -export interface RouteProps { - path: string - page: PageType - name: string - prerender?: boolean - renderMode?: RenderMode - whileLoadingPage?: () => ReactElement | null -} - -/** - * Route is now a "virtual" component - * it is actually never rendered. All the page loading logic happens in active-route-loader - * and all the validation happens within utility functions called from the Router - */ -function Route(props: RouteProps): JSX.Element -function Route(props: RedirectRouteProps): JSX.Element -function Route(props: NotFoundRouteProps): JSX.Element -function Route(_props: RouteProps | RedirectRouteProps | NotFoundRouteProps) { - return <> -} - export interface RouterProps extends Omit { trailingSlashes?: TrailingSlashesTypes @@ -298,4 +275,5 @@ export { namedRoutes as routes, isValidRoute as isRoute, PageType, + RouteProps, } diff --git a/packages/router/src/util.ts b/packages/router/src/util.ts index 3a23ae688144..a55e94da1193 100644 --- a/packages/router/src/util.ts +++ b/packages/router/src/util.ts @@ -2,13 +2,13 @@ import type { ReactElement, ReactNode } from 'react' import { Children, isValidElement } from 'react' +import type { PageType } from './page' import { isNotFoundRoute, isRedirectRoute, isStandardRoute, isValidRoute, } from './route-validators' -import type { PageType } from './router' import { isPrivateNode, isPrivateSetNode, isSetNode } from './Set' export function flattenAll(children: ReactNode): ReactNode[] { @@ -371,54 +371,6 @@ export function flattenSearchParams(queryString: string) { return searchParams } -export interface Spec { - name: string - prerenderLoader: (name?: string) => { default: React.ComponentType } - LazyComponent: - | React.LazyExoticComponent> - | React.ComponentType -} - -export function isSpec( - specOrPage: Spec | React.ComponentType, -): specOrPage is Spec { - return (specOrPage as Spec).LazyComponent !== undefined -} - -/** - * Pages can be imported automatically or manually. Automatic imports are actually - * objects and take the following form (which we call a 'spec'): - * - * const WhateverPage = { - * name: 'WhateverPage', - * LazyComponent: lazy(() => import('src/pages/WhateverPage')) - * prerenderLoader: ... - * } - * - * Manual imports simply load the page: - * - * import WhateverPage from 'src/pages/WhateverPage' - * - * Before passing a "page" to the PageLoader, we will normalize the manually - * imported version into a spec. - */ -export function normalizePage( - specOrPage: Spec | React.ComponentType, -): Spec { - if (isSpec(specOrPage)) { - // Already a spec, just return it. - return specOrPage - } - - // Wrap the Page in a fresh spec, and put it in a promise to emulate - // an async module import. - return { - name: specOrPage.name, - prerenderLoader: () => ({ default: specOrPage }), - LazyComponent: specOrPage, - } -} - /** * Detect if we're in an iframe. *