@before.js/client
Version:
Enables data fetching with any React SSR app that uses React Router 5
233 lines (219 loc) • 7.25 kB
JSX
// @flow strict
import type {
AsyncRoute,
BeforeAction,
BeforeComponentWithRouterProps,
BeforeState,
Context,
InitialProps,
LocationType,
QueryType,
ShouldRenderProps
} from 'Before.component';
import React, { useCallback, useEffect, useReducer, useRef, useMemo, memo } from 'react';
import { withRouter, Switch, Route, type ContextRouter, matchPath } from 'react-router-dom';
import { compose, concat, has, head, ifElse, last, propOr, prop, split } from 'ramda';
import { getQueryString, findRouteByPathname } from './utils';
/**
* Extract the base path from given full pathname, for example given the following url `/foo?bar=2`
* will return `/foo`.
* @func
* @param {string} pathname the pathname to retrieve the pathname
* @returns {string} the base path
*/
const getBasePath: (pathname: string) => string = compose(head, split('#'), head, split('?'));
/**
* Extract the search part of a given full pathname or window.Location, for example given the following url `/foo?bar=2`
* will return `?bar=2`.
* @func
* @param {string|object} pathname | Location
* @returns {string} the querystring
*/
const getSearch: (pathname: string | LocationType) => string = ifElse(
has('search'),
prop('search'),
compose(concat('?'), last, split('?'))
);
/**
* Generates a random string
* @func
* @retuns {string} the generated key
**/
const createLocationKey = () => Math.random().toString(36).substr(2, 5);
/**
* Inject querystring into the react-router match object
* @param {object} context react-router context router
* @param {object} props component inital props
* @param {object} req server-side request object
*/
const getPageProps = (
{ match, location, history }: ContextRouter,
props: InitialProps,
req: { query: QueryType }
) => ({
...props,
history,
query: getQueryString(location, req),
match
});
/**
* Retrieve the initial props from given component route.
* @param {object} route react-router-v4 route object
* @param {object} context object to pass into the getInitialProps
* @param {function} dispatch a callback function
*/
const fetchInitialProps = async (
route: AsyncRoute,
context: Context,
next: (props: ?InitialProps, error?: Error) => void
) => {
try {
const { component } = route;
if (component && component.getInitialProps) {
const data = await component.getInitialProps(context);
next(data);
}
} catch (error) {
next(undefined, error);
}
};
/**
* Dispacher function to be use with `useReducer` hook.
* Manages the state of Before component
* @param {object} state Current state
* @param {object} action The dispatched action
* @return object
*/
const reducer = (state: BeforeState, { location, type }: BeforeAction) => {
switch (type) {
case 'update-location':
return { currentLocation: location };
default:
throw new Error('Invalid reducer type');
}
};
/**
* React Component that wraps all async router components into a Route react-router
* inside a Switch.
* @function
*/
export function Before(props: BeforeComponentWithRouterProps) {
const { data, routes, location, req, history, disableInitialPropsCache } = props;
const [state, dispatch] = useReducer(reducer, {
currentLocation: location
});
const { currentLocation } = state;
const interrupt = useRef(false);
const initialProps = useRef({ [currentLocation.pathname]: data });
const createHistoryMethod = useCallback(
(name: string) => (obj: string | LocationType, state?: { [key: string]: string }) => {
const path: string = getBasePath(propOr(obj, 'pathname', obj));
const route = findRouteByPathname(path, routes);
if (route) {
const search = getSearch(obj);
fetchInitialProps(
route,
{
...props,
match: matchPath(path, route),
location: { pathname: path, hash: '', search, state },
query: getQueryString({ search })
},
props => {
if (!interrupt.current) {
initialProps.current[path] = props;
dispatch({
type: 'update-location',
location: {
hash: '',
key: `before-${createLocationKey()}`,
pathname: path,
search,
state
}
});
history[name](obj, state);
}
}
);
}
},
[history, props, routes]
);
useEffect(() => {
const unlisten = history.listen((location, action) => {
interrupt.current = action === 'POP';
if (disableInitialPropsCache || !initialProps.current[location.pathname]) {
// This solves a weird case when, on an advanced step of the flow, the user does a browser back
const route = findRouteByPathname(location.pathname, routes);
if (route) {
fetchInitialProps(
route,
{
...props,
match: matchPath(location.pathname, route),
location,
query: getQueryString(location)
},
props => {
initialProps.current[location.pathname] = props;
dispatch({ type: 'update-location', location });
interrupt.current = false;
}
);
}
} else {
dispatch({ type: 'update-location', location });
interrupt.current = false;
}
});
return unlisten;
// note(lf): I don't want to re-create this effect each time the react-router history change, which changes on each update to the location.
// Keeping the history object outside the dependency array, will garauntee that we are always listeners is working a expected.
// eslint-disable-next-line
}, []);
const beforeHistory = useMemo(
() => ({
...history,
unstable_location: history.location,
unstable_push: history.push,
unstable_replace: history.replace,
push: createHistoryMethod('push'),
replace: createHistoryMethod('replace'),
location: currentLocation
}),
[history, createHistoryMethod, currentLocation]
);
const routeProps = initialProps.current[currentLocation.pathname];
return (
<Switch location={currentLocation}>
{routes.map(({ component: Component, exact, path }, index) => {
return (
<Route
key={index}
path={path}
exact={exact}
render={(context: ContextRouter) => (
<ShouldRender location={context.location}>
<Component
{...getPageProps({ ...context, history: beforeHistory }, routeProps, req)}
/>
</ShouldRender>
)}
/>
);
})}
</Switch>
);
}
/**
* Wrapper component that has a `shouldComponentUpdate` logic.
* Will only allow rendering on a location change and when the initial props were fetched.
*/
const ShouldRender = memo(
({ children }: ShouldRenderProps) => children,
(prevProps: ShouldRenderProps, nextProps: ShouldRenderProps) => {
return nextProps.location === prevProps.location;
}
);
export default withRouter(Before);