@before.js/server
Version:
Enables data fetching with any React SSR app that uses React Router 5
268 lines (253 loc) • 7.46 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 } from 'react-router-dom';
import {
compose,
concat,
find,
has,
head,
ifElse,
identity,
last,
propOr,
prop,
propEq,
split,
useWith
} from 'ramda';
import { getQueryString } 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('?')
)
);
/**
* Retrieve the current route by a given path.
* @func
* @param {string} pathname
* @param {array} routes an array of route to filter
* @returs {object|undefined} a valid route
**/
const getRouteByPathname: (path: string, routes: Array<AsyncRoute>) => ?AsyncRoute = useWith(find, [
compose(
propEq('path'),
getBasePath
),
identity
]);
/**
* 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 } = 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 = propOr(obj, 'pathname', obj);
const route = getRouteByPathname(path, routes);
if (route) {
const search = getSearch(obj);
fetchInitialProps(
route,
{
...props,
location: { pathname: route.path, hash: '', search, state },
query: getQueryString({ search })
},
props => {
if (!interrupt.current) {
initialProps.current[route.path] = props;
dispatch({
type: 'update-location',
location: {
hash: '',
key: `before-${createLocationKey()}`,
pathname: route.path,
search,
state
}
});
history[name](obj, state);
}
}
);
}
},
[history, props, routes]
);
useEffect(() => {
const unlisten = history.listen((location, action) => {
interrupt.current = action === 'POP';
if (!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 = getRouteByPathname(location.pathname, routes);
if (route) {
fetchInitialProps(
route,
{ ...props, location, query: getQueryString(location) },
props => {
initialProps.current[route.path] = 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);