| null;
-type RouteRedirect = NextRedirectPayload | null;
+ ErrorPageContext,
+ GuardContext,
+ LoadingPageContext,
+ FromRouteContext,
+ GuardDataContext,
+} from './contexts';
+import { resolveGuards, ResolvedGuardStatus } from './resolveGuards';
+import { useRouteChangeEffect } from './useRouteChangeEffect';
+import { Meta, Page, PageComponentType } from './types';
+
+/**
+ * Type checks whether the given page is a React component type.
+ *
+ * @param page the page to type check
+ */
+export function isPageComponentType(page: Page
): page is PageComponentType
{
+ return (
+ !!page && typeof page !== 'string' && typeof page !== 'boolean' && typeof page !== 'number'
+ );
+}
-interface GuardsResolve {
- props: PageProps;
- redirect: RouteRedirect;
+export interface GuardProps extends RouteProps {
+ meta?: Meta;
}
-const Guard: React.FunctionComponent = ({ children, component, meta, render }) => {
- const routeProps = useContext(RouterContext);
- const routePrevProps = usePrevious(routeProps);
- const hasPathChanged = useMemo(
- () => routeProps.location.pathname !== routePrevProps.location.pathname,
- [routePrevProps, routeProps],
- );
- const fromRouteProps = useContext(FromRouteContext);
+export const Guard = withRouter(function GuardWithRouter({
+ // Guard props
+ children,
+ component,
+ meta,
+ render,
+ // Route component props
+ history,
+ location,
+ match,
+ staticContext,
+}) {
+ // Track whether the component is mounted to prevent setting state after unmount
+ const isMountedRef = useRef(true);
+ useEffect(() => {
+ isMountedRef.current = true;
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
const guards = useContext(GuardContext);
- const LoadingPage = useContext(LoadingPageContext);
- const ErrorPage = useContext(ErrorPageContext);
-
- const hasGuards = useMemo(() => !!(guards && guards.length > 0), [guards]);
- const [validationsRequested, setValidationsRequested] = useStateRef(0);
- const [routeValidated, setRouteValidated] = useStateRef(!hasGuards);
- const [routeError, setRouteError] = useStateWhenMounted(null);
- const [routeRedirect, setRouteRedirect] = useStateWhenMounted(null);
- const [pageProps, setPageProps] = useStateWhenMounted({});
-
- /**
- * Memoized callback to get the current number of validations requested.
- * This is used in order to see if new validations were requested in the
- * middle of a validation execution.
- */
- const getValidationsRequested = useCallback(() => validationsRequested.current, [
- validationsRequested,
- ]);
-
- /**
- * Memoized callback to get the next callback function used in guards.
- * Assigns the `props` and `redirect` functions to callback.
- */
- const getNextFn = useCallback((resolve: Function): Next => {
- const getResolveFn = (type: GuardType) => (payload: NextPropsPayload | NextRedirectPayload) =>
- resolve({ type, payload });
- const next = () => resolve({ type: GuardTypes.CONTINUE });
-
- return Object.assign(next, {
- props: getResolveFn(GuardTypes.PROPS),
- redirect: getResolveFn(GuardTypes.REDIRECT),
- });
- }, []);
+ type GuardStatus = { type: 'resolving' } | ResolvedGuardStatus;
+ function getInitialStatus(): GuardStatus {
+ // If there are no guards in context, the route should immediately render
+ if (!guards || guards.length === 0) {
+ return { type: 'render', data: {} };
+ }
+ // Otherwise, the component should start resolving
+ return { type: 'resolving' };
+ }
+ // Create an immutable status state that React will track
+ const [immutableStatus, setStatus] = useState(getInitialStatus);
+ // Create a mutable status variable that we can change for the *current* render
+ let status = immutableStatus;
- /**
- * Runs through a single guard, passing it the current route's props,
- * the previous route's props, and the next callback function. If an
- * error occurs, it will be thrown by the Promise.
- *
- * @param guard the guard function
- * @returns a Promise returning the guard payload
- */
- const runGuard = (guard: GuardFunction): Promise =>
- new Promise(async (resolve, reject) => {
- try {
- const to = {
- ...routeProps,
- meta: meta || {},
- };
- await guard(to, fromRouteProps, getNextFn(resolve));
- } catch (error) {
- reject(error);
- }
- });
+ const routeProps = { history, location, match, staticContext };
+ const fromRouteProps = useContext(FromRouteContext);
+ const routeChangeAbortControllerRef = useRef(null);
+ useRouteChangeEffect(routeProps, async () => {
+ // Abort the guard resolution from the previous route
+ if (routeChangeAbortControllerRef.current) {
+ routeChangeAbortControllerRef.current.abort();
+ routeChangeAbortControllerRef.current = null;
+ }
- /**
- * Loops through all guards in context. If the guard adds new props
- * to the page or causes a redirect, these are tracked in the state
- * constants defined above.
- */
- const resolveAllGuards = async (): Promise => {
- let index = 0;
- let props = {};
- let redirect = null;
- if (guards) {
- while (!redirect && index < guards.length) {
- const { type, payload } = await runGuard(guards[index]);
- if (payload) {
- if (type === GuardTypes.REDIRECT) {
- redirect = payload;
- } else if (type === GuardTypes.PROPS) {
- props = Object.assign(props, payload);
- }
- }
- index += 1;
+ // Determine the initial guard status for the new route
+ const nextStatus = getInitialStatus();
+ // Update status for the *next* render
+ if (isMountedRef.current) {
+ setStatus(nextStatus);
+ }
+ // Update status for the *current* render (based on the intention for the *next* render)
+ status = nextStatus;
+
+ // If the next status is to resolve guards, do so!
+ if (status.type === 'resolving') {
+ const abortController = new AbortController();
+ routeChangeAbortControllerRef.current = abortController;
+ // Resolve the guards to get the render status
+ const resolvedStatus = await resolveGuards(guards || [], {
+ to: routeProps,
+ from: fromRouteProps,
+ meta: meta || {},
+ signal: abortController.signal,
+ });
+ // If the route hasn't changed during async resolution, set the newly resolved status!
+ if (isMountedRef.current && !abortController.signal.aborted) {
+ setStatus(resolvedStatus);
}
}
- return {
- props,
- redirect,
- };
- };
-
- /**
- * Validates the route using the guards. If an error occurs, it
- * will toggle the route error state.
- */
- const validateRoute = async (): Promise => {
- const currentRequests = validationsRequested.current;
+ });
- let pageProps: PageProps = {};
- let routeError: RouteError = null;
- let routeRedirect: RouteRedirect = null;
+ const loadingPage = useContext(LoadingPageContext);
+ const errorPage = useContext(ErrorPageContext);
- try {
- const { props, redirect } = await resolveAllGuards();
- pageProps = props;
- routeRedirect = redirect;
- } catch (error) {
- routeError = error.message || 'Not found.';
+ switch (status.type) {
+ case 'redirect': {
+ return ;
}
- if (currentRequests === getValidationsRequested()) {
- setPageProps(pageProps);
- setRouteError(routeError);
- setRouteRedirect(routeRedirect);
- setRouteValidated(true);
+ case 'render': {
+ return (
+
+
+
+ {children}
+
+
+
+ );
}
- };
- useEffect(() => {
- validateRoute();
- }, []);
-
- useEffect(() => {
- if (hasPathChanged) {
- setValidationsRequested(requests => requests + 1);
- setRouteError(null);
- setRouteRedirect(null);
- setRouteValidated(!hasGuards);
- if (hasGuards) {
- validateRoute();
+ case 'error': {
+ if (isPageComponentType(errorPage)) {
+ return createElement(errorPage, { ...routeProps, error: status.error });
}
+ return {errorPage};
}
- }, [hasPathChanged]);
- if (hasPathChanged) {
- if (hasGuards) {
- return renderPage(LoadingPage, routeProps);
- }
- return null;
- } else if (!routeValidated.current) {
- return renderPage(LoadingPage, routeProps);
- } else if (routeError) {
- return renderPage(ErrorPage, { ...routeProps, error: routeError });
- } else if (routeRedirect) {
- const pathToMatch = typeof routeRedirect === 'string' ? routeRedirect : routeRedirect.pathname;
- const { path, isExact: exact } = routeProps.match;
- if (pathToMatch && !matchPath(pathToMatch, { path, exact })) {
- return ;
+ case 'resolving':
+ default: {
+ if (isPageComponentType(loadingPage)) {
+ return createElement(loadingPage, routeProps);
+ }
+ return {loadingPage};
}
}
- return (
-
-
- {children}
-
-
- );
-};
-
-export default Guard;
+});
diff --git a/package/src/GuardProvider.tsx b/package/src/GuardProvider.tsx
index 7a76652..22a5cd1 100644
--- a/package/src/GuardProvider.tsx
+++ b/package/src/GuardProvider.tsx
@@ -1,40 +1,51 @@
import React, { useContext } from 'react';
-import { __RouterContext as RouterContext } from 'react-router';
-import invariant from 'tiny-invariant';
+import { withRouter, RouteComponentProps } from 'react-router';
import { ErrorPageContext, FromRouteContext, GuardContext, LoadingPageContext } from './contexts';
-import { useGlobalGuards, usePrevious } from './hooks';
-import { GuardProviderProps } from './types';
+import { useGlobalGuards } from './useGlobalGuards';
+import { BaseGuardProps } from './types';
+import { useRouteChangeEffect } from './useRouteChangeEffect';
-const GuardProvider: React.FunctionComponent = ({
- children,
- guards,
- ignoreGlobal,
- loading,
- error,
-}) => {
- const routerContext = useContext(RouterContext);
- invariant(!!routerContext, 'You should not use outside a ');
+export type GuardProviderProps = BaseGuardProps;
- const from = usePrevious(routerContext);
- const providerGuards = useGlobalGuards(guards, ignoreGlobal);
+export const GuardProvider = withRouter(
+ function GuardProviderWithRouter({
+ // Guard provider props
+ children,
+ guards,
+ ignoreGlobal,
+ loading: loadingPageOverride,
+ error: errorPageOverride,
+ // Route component props
+ history,
+ location,
+ match,
+ staticContext,
+ }) {
+ const routeProps = { history, location, match, staticContext };
+ const fromRouteProps = useRouteChangeEffect(routeProps, () => {});
+ const parentFromRouteProps = useContext(FromRouteContext);
- const loadingPage = useContext(LoadingPageContext);
- const errorPage = useContext(ErrorPageContext);
+ const providerGuards = useGlobalGuards(guards, ignoreGlobal);
- return (
-
-
-
- {children}
-
-
-
- );
-};
+ const loadingPage = useContext(LoadingPageContext);
+ const errorPage = useContext(ErrorPageContext);
-GuardProvider.defaultProps = {
- guards: [],
- ignoreGlobal: false,
-};
-
-export default GuardProvider;
+ return (
+
+
+
+ {/**
+ * Prioritize the parent FromRoute props over the child (which uses the closest Route's match)
+ * https://reactrouter.com/web/api/withRouter
+ */}
+
+ {children}
+
+
+
+
+ );
+ },
+);
diff --git a/package/src/GuardedRoute.tsx b/package/src/GuardedRoute.tsx
index 2d6fcdf..b9f805e 100644
--- a/package/src/GuardedRoute.tsx
+++ b/package/src/GuardedRoute.tsx
@@ -1,19 +1,22 @@
import React, { useContext } from 'react';
-import { Route } from 'react-router-dom';
+import { Route, RouteProps } from 'react-router-dom';
import invariant from 'tiny-invariant';
-import ContextWrapper from './ContextWrapper';
-import Guard from './Guard';
+import { Guard } from './Guard';
import { ErrorPageContext, GuardContext, LoadingPageContext } from './contexts';
-import { useGlobalGuards } from './hooks';
-import { GuardedRouteProps, PageComponent } from './types';
+import { useGlobalGuards } from './useGlobalGuards';
+import { BaseGuardProps, Meta } from './types';
-const GuardedRoute: React.FunctionComponent = ({
+export interface GuardedRouteProps extends BaseGuardProps, RouteProps {
+ meta?: Meta;
+}
+
+export const GuardedRoute: React.FunctionComponent = ({
children,
component,
- error,
+ error: errorPageOverride,
guards,
ignoreGlobal,
- loading,
+ loading: loadingPageOverride,
meta,
render,
path,
@@ -24,28 +27,22 @@ const GuardedRoute: React.FunctionComponent = ({
const routeGuards = useGlobalGuards(guards, ignoreGlobal);
+ const loadingPage = useContext(LoadingPageContext);
+ const errorPage = useContext(ErrorPageContext);
+
return (
- (
-
- context={LoadingPageContext} value={loading}>
- context={ErrorPageContext} value={error}>
-
- {children}
-
-
-
-
- )}
- />
+
+
+
+
+
+ {children}
+
+
+
+
+
);
};
-
-GuardedRoute.defaultProps = {
- guards: [],
- ignoreGlobal: false,
-};
-
-export default GuardedRoute;
diff --git a/package/src/__tests__/renderPage.test.tsx b/package/src/__tests__/renderPage.test.tsx
deleted file mode 100644
index b78eb0b..0000000
--- a/package/src/__tests__/renderPage.test.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import React, { Fragment, createElement } from 'react';
-import { shallow } from 'enzyme';
-import renderPage from '../renderPage';
-
-const testNullRender = (data: any) => {
- expect(renderPage(data)).toEqual(null);
-};
-
-const testFragmentRender = (data: any) => {
- const page = renderPage(data) as React.ReactElement;
- const Element = ({ data }: Record) => {data};
- const testPage = shallow();
- expect(testPage.equals(page)).toEqual(true);
-};
-
-const Component = ({ text }: Record) => {text}
;
-Component.defaultProps = {
- text: 'ok',
-};
-
-describe('renderPage', () => {
- it('renders null as null', () => {
- testNullRender(null);
- });
-
- it('renders undefined as null', () => {
- testNullRender(undefined);
- });
-
- it('renders empty string as null', () => {
- testNullRender('');
- });
-
- it('renders string as fragment', () => {
- testFragmentRender('sample text');
- });
-
- it('renders false boolean as null', () => {
- testNullRender(false);
- });
-
- it('renders true boolean as fragment', () => {
- testFragmentRender(true);
- });
-
- it('renders 0 number as null', () => {
- testNullRender(0);
- });
-
- it('renders positive number as fragment', () => {
- testFragmentRender(42);
- });
-
- it('renders negative number as fragment', () => {
- testFragmentRender(-42);
- });
-
- it('renders component without props', () => {
- const page = renderPage(Component) as React.ReactElement;
- const testPage = createElement(Component);
- expect(shallow(page).text()).toEqual(Component.defaultProps.text);
- expect(page).toEqual(testPage);
- });
-
- it('renders component with props', () => {
- const text = 'Hello world';
- const page = renderPage(Component, { text }) as React.ReactElement;
- const testPage = createElement(Component, { text });
- expect(shallow(page).text()).toEqual(text);
- expect(page).toEqual(testPage);
- });
-});
diff --git a/package/src/hooks/__tests__/useGlobalGuards.test.tsx b/package/src/__tests__/useGlobalGuards.test.tsx
similarity index 96%
rename from package/src/hooks/__tests__/useGlobalGuards.test.tsx
rename to package/src/__tests__/useGlobalGuards.test.tsx
index 8a53008..a574b53 100644
--- a/package/src/hooks/__tests__/useGlobalGuards.test.tsx
+++ b/package/src/__tests__/useGlobalGuards.test.tsx
@@ -1,8 +1,8 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
-import { GuardFunction } from '../../types';
-import { GuardContext } from '../../contexts';
-import useGlobalGuards from '../useGlobalGuards';
+import { GuardFunction } from '../types';
+import { GuardContext } from '../contexts';
+import { useGlobalGuards } from '../useGlobalGuards';
const guardOne: GuardFunction = (to, from, next) => next();
const guardTwo: GuardFunction = (to, from, next) => next.props({});
diff --git a/package/src/contexts.ts b/package/src/contexts.ts
index 145346e..7fa9e0d 100644
--- a/package/src/contexts.ts
+++ b/package/src/contexts.ts
@@ -1,11 +1,13 @@
import { createContext } from 'react';
import { RouteComponentProps } from 'react-router-dom';
-import { PageComponent, GuardFunction } from './types';
+import { GuardFunction, ErrorPage, LoadingPage } from './types';
-export const ErrorPageContext = createContext(null);
+export const ErrorPageContext = createContext(null);
export const FromRouteContext = createContext(null);
export const GuardContext = createContext(null);
-export const LoadingPageContext = createContext(null);
+export const GuardDataContext = createContext({});
+
+export const LoadingPageContext = createContext(null);
diff --git a/package/src/hooks/__tests__/usePrevious.test.tsx b/package/src/hooks/__tests__/usePrevious.test.tsx
deleted file mode 100644
index 018ecc8..0000000
--- a/package/src/hooks/__tests__/usePrevious.test.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import React from 'react';
-import { mount, ReactWrapper } from 'enzyme';
-import usePrevious from '../usePrevious';
-
-interface UsePreviousHookProps {
- value?: string;
-}
-const UsePreviousHook: React.FC = ({ value }) => {
- const previousValue = usePrevious(value);
- return {previousValue}
;
-};
-
-describe('usePrevious', () => {
- let wrapper: ReactWrapper | null = null;
-
- afterEach(() => {
- if (wrapper) {
- wrapper.unmount();
- }
- wrapper = null;
- });
-
- it('should render', () => {
- wrapper = mount();
- expect(wrapper.exists()).toBeTruthy();
- });
-
- it('should set init value', () => {
- const VALUE = 'value';
- wrapper = mount();
- expect(wrapper.text()).toEqual(VALUE);
- });
-
- it('stores the previous value of given variable', () => {
- const VALUE_1 = 'hello';
- const VALUE_2 = 'world';
- const VALUE_3 = 'okay';
-
- let value = VALUE_1;
- wrapper = mount();
-
- let hookValue = wrapper.text();
- expect(hookValue).toEqual(value);
- expect(hookValue).toEqual(VALUE_1);
-
- value = VALUE_2;
- wrapper.setProps({ value });
- hookValue = wrapper.text();
- expect(hookValue).toEqual(VALUE_1);
-
- value = VALUE_3;
- wrapper.setProps({ value });
- hookValue = wrapper.text();
- expect(hookValue).toEqual(VALUE_2);
- });
-});
diff --git a/package/src/hooks/__tests__/useStateRef.test.tsx b/package/src/hooks/__tests__/useStateRef.test.tsx
deleted file mode 100644
index 30f0786..0000000
--- a/package/src/hooks/__tests__/useStateRef.test.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import React from 'react';
-import { act } from 'react-dom/test-utils';
-import { mount, ReactWrapper } from 'enzyme';
-import useStateRef, { State, SetState } from '../useStateRef';
-
-interface UseStateRefHookProps {
- value?: string;
-}
-const UseStateRefHook: React.FC = ({ value }) => {
- const stateRef = useStateRef(value);
- return ;
-};
-
-function getState(wrapper: ReactWrapper): [State, SetState] {
- return wrapper.find('div').prop('data-state-ref');
-}
-
-describe('usePrevious', () => {
- const INIT_VALUE = 'value';
- let wrapper: ReactWrapper | null = null;
-
- afterEach(() => {
- if (wrapper) {
- wrapper.unmount();
- }
- wrapper = null;
- });
-
- it('should render', () => {
- wrapper = mount();
- expect(wrapper.exists()).toBeTruthy();
- });
-
- it('should return a ref for the state value', () => {
- wrapper = mount();
- const [state] = getState(wrapper);
- expect(typeof state).toEqual('object');
- expect(state).toHaveProperty('current');
- });
-
- it('should set initial state to undefined with no passed value', () => {
- wrapper = mount();
- const [state] = getState(wrapper);
- expect(state.current).toEqual(undefined);
- });
-
- it('should set initial state to passed value', () => {
- wrapper = mount();
- const [state] = getState(wrapper);
- expect(state.current).toEqual(INIT_VALUE);
- });
-
- it('should update value to new value passed to setState', () => {
- const VALUE = 'value';
- wrapper = mount();
- const [state, setState] = getState(wrapper);
- expect(state.current).toEqual(VALUE);
-
- const NEW_VALUE = 'new value';
- act(() => {
- setState(NEW_VALUE);
- });
- expect(state.current).toEqual(NEW_VALUE);
- });
-});
diff --git a/package/src/hooks/__tests__/useStateWhenMounted.test.tsx b/package/src/hooks/__tests__/useStateWhenMounted.test.tsx
deleted file mode 100644
index 20d9974..0000000
--- a/package/src/hooks/__tests__/useStateWhenMounted.test.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import React from 'react';
-import { act } from 'react-dom/test-utils';
-import { mount, ReactWrapper } from 'enzyme';
-import useStateWhenMounted, { SetState } from '../useStateWhenMounted';
-
-interface UseStateWhenMountedHookProps {
- value?: string;
-}
-const UseStateWhenMountedHook: React.FC = ({ value }) => {
- const stateRef = useStateWhenMounted(value);
- return ;
-};
-
-function getState(wrapper: ReactWrapper): [T, SetState] {
- return wrapper.find('div').prop('data-state-ref');
-}
-
-describe('usePrevious', () => {
- const INIT_VALUE = 'value';
- let wrapper: ReactWrapper | null = null;
-
- afterEach(() => {
- if (wrapper && wrapper.exists()) {
- wrapper.unmount();
- }
- wrapper = null;
- });
-
- it('should render', () => {
- wrapper = mount();
- expect(wrapper.exists()).toBeTruthy();
- });
-
- it('should set initial state to undefined with no passed value', () => {
- wrapper = mount();
- const [state] = getState(wrapper);
- expect(state).toEqual(undefined);
- });
-
- it('should set initial state to passed value', () => {
- wrapper = mount();
- const [state] = getState(wrapper);
- expect(state).toEqual(INIT_VALUE);
- });
-
- it('should prevent value update when component is unmounted', () => {
- const VALUE = 'value';
- wrapper = mount();
- const [state, setState] = getState(wrapper);
- expect(state).toEqual(VALUE);
-
- wrapper.unmount();
-
- const NEW_VALUE = 'new value';
- act(() => {
- setState(NEW_VALUE);
- });
- expect(state).toEqual(VALUE);
- });
-});
diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts
deleted file mode 100644
index 6055d87..0000000
--- a/package/src/hooks/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export { default as useGlobalGuards } from './useGlobalGuards';
-export { default as usePrevious } from './usePrevious';
-export { default as useStateRef } from './useStateRef';
-export { default as useStateWhenMounted } from './useStateWhenMounted';
diff --git a/package/src/hooks/useGlobalGuards.ts b/package/src/hooks/useGlobalGuards.ts
deleted file mode 100644
index 549d005..0000000
--- a/package/src/hooks/useGlobalGuards.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { useContext, useMemo, useDebugValue } from 'react';
-import { GuardContext } from '../contexts';
-import { GuardFunction } from '../types';
-
-/**
- * React hook for creating the guards array for a Guarded
- * component.
- *
- * @param guards the component-level guards
- * @param ignoreGlobal whether to ignore the global guards or not
- * @returns the guards to use on the component
- */
-const useGlobalGuards = (guards: GuardFunction[] = [], ignoreGlobal: boolean = false) => {
- const globalGuards = useContext(GuardContext);
-
- const componentGuards = useMemo(() => {
- if (ignoreGlobal) {
- return [...guards];
- }
- return [...(globalGuards || []), ...guards];
- }, [guards, ignoreGlobal]);
-
- useDebugValue(componentGuards.map(({ name }) => name).join(' | '));
-
- return componentGuards;
-};
-
-export default useGlobalGuards;
diff --git a/package/src/hooks/usePrevious.ts b/package/src/hooks/usePrevious.ts
deleted file mode 100644
index 4906c5e..0000000
--- a/package/src/hooks/usePrevious.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { useDebugValue, useEffect, useRef } from 'react';
-
-/**
- * React hook for storing the previous value of the
- * given value.
- *
- * @param value the value to store
- * @returns the previous value
- */
-function usePrevious(value: T): T {
- const ref = useRef(value);
-
- useEffect(() => {
- ref.current = value;
- });
-
- useDebugValue(ref.current);
-
- return ref.current;
-}
-
-export default usePrevious;
diff --git a/package/src/hooks/useStateRef.ts b/package/src/hooks/useStateRef.ts
deleted file mode 100644
index 7142363..0000000
--- a/package/src/hooks/useStateRef.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { useRef } from 'react';
-import useStateWhenMounted from './useStateWhenMounted';
-
-type NotFunc = Exclude;
-
-type SetStateFuncAction = (prevState: NotFunc) => NotFunc;
-type SetStateAction = NotFunc | SetStateFuncAction;
-export type SetState = (newState: SetStateAction) => void;
-
-export type State = React.MutableRefObject>;
-
-/**
- * React hook that provides a similar API to the `useState`
- * hook, but performs updates using refs instead of asynchronous
- * actions.
- *
- * @param initialState the initial state of the state variable
- * @returns an array containing a ref of the state variable and a setter
- * function for the state
- */
-function useStateRef(initialState: NotFunc): [State, SetState] {
- const state = useRef(initialState);
- const [, setTick] = useStateWhenMounted(0);
-
- const setState: SetState = newState => {
- if (typeof newState === 'function') {
- state.current = (newState as SetStateFuncAction)(state.current);
- } else {
- state.current = newState;
- }
- setTick(tick => tick + 1);
- };
-
- return [state, setState];
-}
-
-export default useStateRef;
diff --git a/package/src/hooks/useStateWhenMounted.ts b/package/src/hooks/useStateWhenMounted.ts
deleted file mode 100644
index eebaded..0000000
--- a/package/src/hooks/useStateWhenMounted.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { useEffect, useRef, useState, useDebugValue } from 'react';
-
-export type SetState = (newState: React.SetStateAction) => void;
-
-/**
- * React hook for only updating a component's state when the component is still mounted.
- * This is useful for state variables that depend on asynchronous operations to update.
- *
- * The interface in which this hook is used is identical to that of `useState`.
- *
- * @param initialState the initial value of the state variable
- * @returns an array containing the state variable and the function to update
- * the state
- */
-function useStateWhenMounted(initialState: T): [T, SetState] {
- const mounted = useRef(true);
-
- const [state, setState] = useState(initialState);
-
- const setStateWhenMounted: SetState = newState => {
- if (mounted.current) {
- setState(newState);
- }
- };
-
- useEffect(
- () => () => {
- mounted.current = false;
- },
- [],
- );
-
- useDebugValue(state);
-
- return [state, setStateWhenMounted];
-}
-
-export default useStateWhenMounted;
diff --git a/package/src/index.ts b/package/src/index.ts
index cdf1dbe..ffb9ddf 100644
--- a/package/src/index.ts
+++ b/package/src/index.ts
@@ -1,10 +1,11 @@
-export { default as GuardProvider } from './GuardProvider';
-export { default as GuardedRoute } from './GuardedRoute';
+export { GuardProvider, GuardProviderProps } from './GuardProvider';
+export { GuardedRoute, GuardedRouteProps } from './GuardedRoute';
+export { useGuardData } from './useGuardData';
export {
BaseGuardProps,
- GuardedRouteProps,
GuardFunction,
- GuardProviderProps,
- Next,
- PageComponent,
+ NextFunction,
+ Page,
+ LoadingPageComponentType,
+ ErrorPageComponentType,
} from './types';
diff --git a/package/src/renderPage.tsx b/package/src/renderPage.tsx
deleted file mode 100644
index d44d09b..0000000
--- a/package/src/renderPage.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import React, { createElement, Fragment } from 'react';
-import { PageComponent } from './types';
-
-type BaseProps = Record;
-
-/**
- * Renders a page with the given props.
- *
- * @param page the page component to render
- * @param props the props to pass to the page
- * @returns the page component
- */
-function renderPage(
- page: PageComponent,
- props?: Props,
-): React.ReactElement | null {
- if (!page) {
- return null;
- } else if (typeof page !== 'string' && typeof page !== 'boolean' && typeof page !== 'number') {
- return createElement(page, props || {});
- }
- return {page};
-}
-
-export default renderPage;
diff --git a/package/src/resolveGuards.ts b/package/src/resolveGuards.ts
new file mode 100644
index 0000000..fae5557
--- /dev/null
+++ b/package/src/resolveGuards.ts
@@ -0,0 +1,80 @@
+import {
+ GuardFunction,
+ NextFunction,
+ NextDataPayload,
+ NextRedirectPayload,
+ GuardFunctionContext,
+ NextContinueAction,
+ NextDataAction,
+ NextRedirectAction,
+} from './types';
+
+export type ResolvedGuardStatus =
+ | { type: 'error'; error: unknown }
+ | { type: 'redirect'; redirect: NextRedirectPayload }
+ | { type: 'render'; data: NextDataPayload };
+
+export const NextFunctionFactory = {
+ /**
+ * Builds a new next function using the given `resolve` callback.
+ */
+ build(): NextFunction<{}> {
+ function next(): NextContinueAction {
+ return { type: 'continue' };
+ }
+
+ return Object.assign(next, {
+ data(payload: NextDataPayload): NextDataAction {
+ return { type: 'data', payload };
+ },
+ redirect(payload: NextRedirectPayload): NextRedirectAction {
+ return { type: 'redirect', payload };
+ },
+ });
+ },
+};
+
+/**
+ * Resolves a list of guards in the given context. Resolution follows as such:
+ * - If any guard resolves to a redirect, return that redirect
+ * - If any guard throws an error, return that error
+ * - Otherwise, return all merged data
+ *
+ * If the abort signal in context is aborted, bubble up that error.
+ *
+ * @param guards the list of guards to resolve
+ * @param context the context of these guards
+ * @returns a Promise returning the resolved guards' status
+ */
+export async function resolveGuards(
+ guards: GuardFunction[],
+ context: GuardFunctionContext,
+): Promise {
+ try {
+ let data: NextDataPayload = {};
+ for (const guard of guards) {
+ // If guard resolution has been canceled *before* running guard, bubble up an AbortError
+ if (context.signal.aborted) {
+ throw new DOMException('Aborted', 'AbortError');
+ }
+
+ // Run the guard and get the resolved action
+ const action = await guard(context, NextFunctionFactory.build());
+ switch (action.type) {
+ case 'redirect': {
+ // If the guard calls for a redirect, do so immediately!
+ return { type: 'redirect', redirect: action.payload };
+ }
+ case 'data': {
+ // Otherwise, continue to merge data
+ data = Object.assign(data, action.payload);
+ break;
+ }
+ }
+ }
+ // Then return the props after all guards have resolved
+ return { type: 'render', data };
+ } catch (error) {
+ return { type: 'error', error };
+ }
+}
diff --git a/package/src/types.ts b/package/src/types.ts
index 197f2e4..f0ef5ce 100644
--- a/package/src/types.ts
+++ b/package/src/types.ts
@@ -1,83 +1,89 @@
import { ComponentType } from 'react';
import { LocationDescriptor } from 'history';
-import { RouteComponentProps, RouteProps } from 'react-router-dom';
+import { RouteComponentProps } from 'react-router-dom';
-/**
- * General
- */
+///////////////////////////////
+// General
+///////////////////////////////
export type Meta = Record;
-export type RouteMatchParams = Record;
-
-/**
- * Guard Function Types
- */
-export const GuardTypes = Object.freeze({
- CONTINUE: 'CONTINUE',
- PROPS: 'PROPS',
- REDIRECT: 'REDIRECT',
-});
-
-export type GUARD_TYPES_CONTINUE = typeof GuardTypes.CONTINUE;
-export type GUARD_TYPES_PROPS = typeof GuardTypes.PROPS;
-export type GUARD_TYPES_REDIRECT = typeof GuardTypes.REDIRECT;
-export type GuardType = GUARD_TYPES_CONTINUE | GUARD_TYPES_PROPS | GUARD_TYPES_REDIRECT;
+///////////////////////////////
+// Next Functions
+///////////////////////////////
export interface NextContinueAction {
- type: GUARD_TYPES_CONTINUE;
- payload?: any;
+ type: 'continue';
}
-export type NextPropsPayload = Record;
-export interface NextPropsAction {
- type: GUARD_TYPES_PROPS;
- payload: NextPropsPayload;
+export type NextDataPayload = Record;
+export interface NextDataAction {
+ type: 'data';
+ payload: NextDataPayload;
}
export type NextRedirectPayload = LocationDescriptor;
export interface NextRedirectAction {
- type: GUARD_TYPES_REDIRECT;
+ type: 'redirect';
payload: NextRedirectPayload;
}
-export type NextAction = NextContinueAction | NextPropsAction | NextRedirectAction;
+export type NextAction = NextContinueAction | NextDataAction | NextRedirectAction;
-export interface Next {
- (): void;
- props(props: NextPropsPayload): void;
- redirect(to: LocationDescriptor): void;
+export interface NextFunction {
+ /** Resolve the guard and continue to the next, if any. */
+ (): NextContinueAction;
+ /** Pass the data to the resolved route and continue to the next, if any. */
+ data(data: Data): NextDataAction;
+ /** Redirect to the given route. */
+ redirect(to: LocationDescriptor): NextRedirectAction;
}
-export type GuardFunctionRouteProps = RouteComponentProps;
-export type GuardToRoute = GuardFunctionRouteProps & {
+///////////////////////////////
+// Guards
+///////////////////////////////
+export interface GuardFunctionContext {
+ /** The route being navigated to. */
+ to: RouteComponentProps>;
+ /** The route being navigated from, if any. */
+ from: RouteComponentProps> | null;
+ /** Metadata attached on the `to` route. */
meta: Meta;
-};
-export type GuardFunction = (
- to: GuardToRoute,
- from: GuardFunctionRouteProps | null,
- next: Next,
-) => void;
+ /**
+ * A signal that determines if the current guard resolution has been aborted.
+ *
+ * Attach to `fetch` calls to cancel outdated requests before they're resolved.
+ */
+ signal: AbortSignal;
+}
+
+export type GuardFunction = (
+ /** Context for this guard's execution */
+ context: GuardFunctionContext,
+ /** The guard's next function */
+ next: NextFunction,
+) => NextAction | Promise;
-/**
- * Page Component Types
- */
-export type PageComponent = ComponentType | null | undefined | string | boolean | number;
+///////////////////////////////
+// Page Types
+///////////////////////////////
+export type PageComponentType = ComponentType;
+export type Page = PageComponentType
| null | string | boolean | number;
-/**
- * Props
- */
+export type LoadingPage = Page;
+export type ErrorPage = Page<{ error: unknown }>;
+
+export type LoadingPageComponentType = PageComponentType;
+export type ErrorPageComponentType = PageComponentType<{ error: unknown }>;
+
+///////////////////////////////
+// Props
+///////////////////////////////
export interface BaseGuardProps {
+ /** Guards to attach as middleware. */
guards?: GuardFunction[];
+ /** Whether to ignore guards attached to parent providers. */
ignoreGlobal?: boolean;
- loading?: PageComponent;
- error?: PageComponent;
+ /** A custom loading page component. */
+ loading?: LoadingPage;
+ /** A custom error page component. */
+ error?: ErrorPage;
}
-
-export type PropsWithMeta = T & {
- meta?: Meta;
-};
-
-export type GuardProviderProps = BaseGuardProps;
-export type GuardedRouteProps = PropsWithMeta;
-export type GuardProps = PropsWithMeta & {
- name?: string | string[];
-};
diff --git a/package/src/useGlobalGuards.ts b/package/src/useGlobalGuards.ts
new file mode 100644
index 0000000..6088c1c
--- /dev/null
+++ b/package/src/useGlobalGuards.ts
@@ -0,0 +1,20 @@
+import { useContext } from 'react';
+import { GuardContext } from './contexts';
+import { GuardFunction } from './types';
+
+/**
+ * React hook for creating the guards array for a Guarded component.
+ *
+ * @param guards the component-level guards
+ * @param ignoreGlobal whether to ignore the global guards or not
+ * @returns the guards to use on the component
+ */
+export const useGlobalGuards = (guards: GuardFunction[] = [], ignoreGlobal: boolean = false) => {
+ const globalGuards = useContext(GuardContext);
+
+ if (ignoreGlobal) {
+ return [...guards];
+ } else {
+ return [...(globalGuards || []), ...guards];
+ }
+};
diff --git a/package/src/useGuardData.tsx b/package/src/useGuardData.tsx
new file mode 100644
index 0000000..8d2b858
--- /dev/null
+++ b/package/src/useGuardData.tsx
@@ -0,0 +1,6 @@
+import { useContext } from 'react';
+import { GuardDataContext } from './contexts';
+
+export function useGuardData() {
+ return useContext(GuardDataContext) as P;
+}
diff --git a/package/src/useRouteChangeEffect.ts b/package/src/useRouteChangeEffect.ts
new file mode 100644
index 0000000..6b59820
--- /dev/null
+++ b/package/src/useRouteChangeEffect.ts
@@ -0,0 +1,78 @@
+import { useRef } from 'react';
+import { RouteComponentProps } from 'react-router';
+
+/**
+ * Compares the matched route's path and params to check whether the
+ * route has changed.
+ *
+ * @param routeA a route to compare
+ * @param routeB a route to compare
+ * @returns whether the route has changed
+ */
+export function getHasRouteChanged(
+ routeA: RouteComponentProps>,
+ routeB: RouteComponentProps>,
+) {
+ // Perform shallow string comparison to check that path hasn't changed
+ const doPathsMatch = routeA.match.path === routeB.match.path;
+ if (!doPathsMatch) {
+ return true;
+ }
+
+ // Perform deep object comparison to check that params haven't changed
+ // NOTE: the param keys won't change so long as path doesn't change (which is already checked above)
+ const doParamsMatch = Object.keys(routeA.match.params).every(
+ key => routeA.match.params[key] === routeB.match.params[key],
+ );
+ if (!doParamsMatch) {
+ return true;
+ }
+
+ // If neither path nor params have changed, then route has stayed the same!
+ return false;
+}
+
+/**
+ * Custom effect hook that runs on init and whenever the route changes.
+ *
+ * This hook runs inline with React's render function to ensure state is updated
+ * immediately for the upcoming render. This is preferable to `useEffect` or
+ * `useLayoutEffect` which only updates state _after_ a component has already rendered.
+ *
+ * @param route the current route
+ * @param onInitOrChange a callback for when the route changes (and on init)
+ * @returns the previous route (if any)
+ */
+export function useRouteChangeEffect(
+ route: RouteComponentProps,
+ onInitOrChange: () => void,
+): RouteComponentProps | null {
+ // Store whether effect has run before in ref
+ const hasEffectRunRef = useRef(false);
+
+ // Store the current and previous values of route in ref
+ // https://dev.to/chrismilson/problems-with-useprevious-me
+ const routeStoreRef = useRef<{
+ target: RouteComponentProps;
+ previous: RouteComponentProps | null;
+ }>({
+ target: route,
+ previous: null,
+ });
+
+ if (getHasRouteChanged(routeStoreRef.current.target, route)) {
+ // When the route changes, update previous + target values and run the effect
+ routeStoreRef.current.previous = routeStoreRef.current.target;
+ routeStoreRef.current.target = route;
+ onInitOrChange();
+ } else if (!hasEffectRunRef.current) {
+ // Otherwise if the effect hasn't run before, run it now!
+ onInitOrChange();
+ }
+
+ // Always set hasEffectRun to true (to prevent duplicate runs)
+ hasEffectRunRef.current = true;
+
+ // Then return the previous route (if any)
+ return routeStoreRef.current.previous;
+}