Server-side API calling/preloading. (#224)

* make sure BACKEND_URL gets set for server in production mode

* ssr api calls by path

* turn off redux logger on server

* massage preloaded state (BNify JSONed BNs)

* make sure fetchProposal returns async/promise

* make sure render works on ssr (check window refs)

* linting issue
This commit is contained in:
AMStrix 2018-11-21 21:17:49 -06:00 committed by Daniel Ternyak
parent f8910b1e09
commit a95a8ff080
9 changed files with 107 additions and 21 deletions

View File

@ -63,11 +63,15 @@ export class ProposalDetail extends React.Component<Props, State> {
} else {
this.checkBodyOverflow();
}
window.addEventListener('resize', this.checkBodyOverflow);
if (typeof window !== 'undefined') {
window.addEventListener('resize', this.checkBodyOverflow);
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.checkBodyOverflow);
if (typeof window !== 'undefined') {
window.removeEventListener('resize', this.checkBodyOverflow);
}
}
componentDidUpdate() {
@ -121,7 +125,7 @@ export class ProposalDetail extends React.Component<Props, State> {
<div className="Proposal-top">
<div className="Proposal-top-social">
<SocialShare
url={window.location.href}
url={(typeof window !== 'undefined' && window.location.href) || ''}
title={`${proposal.title} needs funding on Grant-io!`}
text={`${
proposal.title

View File

@ -8,10 +8,12 @@ import { BrowserRouter as Router } from 'react-router-dom';
import { PersistGate } from 'redux-persist/integration/react';
import { I18nextProvider } from 'react-i18next';
import { configureStore } from 'store/configure';
import { massageSerializedState } from 'utils/api';
import Routes from './Routes';
import i18n from './i18n';
const initialState = window && (window as any).__PRELOADED_STATE__;
const initialState =
window && massageSerializedState((window as any).__PRELOADED_STATE__);
const { store, persistor } = configureStore(initialState);
const i18nLanguage = window && (window as any).__PRELOADED_I18N__;
i18n.changeLanguage(i18nLanguage.locale);

View File

@ -23,8 +23,8 @@ export function fetchProposals() {
export type TFetchProposal = typeof fetchProposal;
export function fetchProposal(proposalId: ProposalWithCrowdFund['proposalId']) {
return (dispatch: Dispatch<any>) => {
dispatch({
return async (dispatch: Dispatch<any>) => {
return dispatch({
type: types.PROPOSAL_DATA,
payload: async () => {
return (await getProposal(proposalId)).data;

View File

@ -13,7 +13,7 @@ const sagaMiddleware = createSagaMiddleware();
type MiddleWare = ThunkMiddleware | SagaMiddleware<any> | any;
const bindMiddleware = (middleware: MiddleWare[]) => {
if (process.env.NODE_ENV !== 'production') {
if (process.env.NODE_ENV !== 'production' && typeof window !== 'undefined') {
const { createLogger } = require('redux-logger');
const logger = createLogger({
collapsed: true,

View File

@ -1,6 +1,7 @@
import BN from 'bn.js';
import { TeamMember, CrowdFund, ProposalWithCrowdFund } from 'types';
import { TeamMember, CrowdFund, ProposalWithCrowdFund, UserProposal } from 'types';
import { socialAccountsToUrls, socialUrlsToAccounts } from 'utils/social';
import { AppState } from 'store/reducers';
export function formatTeamMemberForPost(user: TeamMember) {
return {
@ -28,35 +29,35 @@ export function formatTeamMemberFromGet(user: any): TeamMember {
};
}
export function formatCrowdFundFromGet(crowdFund: CrowdFund): CrowdFund {
export function formatCrowdFundFromGet(crowdFund: CrowdFund, base = 10): CrowdFund {
const bnKeys = ['amountVotingForRefund', 'balance', 'funded', 'target'] as Array<
keyof CrowdFund
>;
bnKeys.forEach(k => {
crowdFund[k] = new BN(crowdFund[k] as string);
crowdFund[k] = new BN(crowdFund[k] as string, base);
});
crowdFund.milestones = crowdFund.milestones.map(ms => {
ms.amount = new BN(ms.amount);
ms.amountAgainstPayout = new BN(ms.amountAgainstPayout);
ms.amount = new BN(ms.amount, base);
ms.amountAgainstPayout = new BN(ms.amountAgainstPayout, base);
return ms;
});
crowdFund.contributors = crowdFund.contributors.map(c => {
c.contributionAmount = new BN(c.contributionAmount);
c.contributionAmount = new BN(c.contributionAmount, base);
return c;
});
return crowdFund;
}
export function formatProposalFromGet(proposal: ProposalWithCrowdFund) {
proposal.team = proposal.team.map(formatTeamMemberFromGet);
proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title);
proposal.crowdFund = formatCrowdFundFromGet(proposal.crowdFund);
for (let i = 0; i < proposal.crowdFund.milestones.length; i++) {
proposal.milestones[i] = {
...proposal.milestones[i],
...proposal.crowdFund.milestones[i],
};
}
proposal.team = proposal.team.map(formatTeamMemberFromGet);
proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title);
proposal.crowdFund = formatCrowdFundFromGet(proposal.crowdFund);
return proposal;
}
@ -79,3 +80,32 @@ export function extractProposalIdFromUrl(slug: string) {
}
return proposalId;
}
// pre-hydration massage (BNify JSONed BNs)
export function massageSerializedState(state: AppState) {
// proposals
state.proposal.proposals.forEach(p => {
formatCrowdFundFromGet(p.crowdFund, 16);
for (let i = 0; i < p.crowdFund.milestones.length; i++) {
p.milestones[i] = {
...p.milestones[i],
...p.crowdFund.milestones[i],
};
}
});
// users
const bnUserProp = (p: UserProposal) => {
p.funded = new BN(p.funded, 16);
p.target = new BN(p.target, 16);
return p;
};
Object.values(state.users.map).forEach(user => {
user.createdProposals.forEach(bnUserProp);
user.fundedProposals.forEach(bnUserProp);
user.comments.forEach(c => {
c.proposal = bnUserProp(c.proposal);
});
});
return state;
}

View File

@ -50,6 +50,10 @@ envProductionRequiredHandler(
'https://eip-712.herokuapp.com/contract/factory',
);
if (!process.env.BACKEND_URL) {
process.env.BACKEND_URL = 'http://localhost:5000';
}
const appDirectory = fs.realpathSync(process.cwd());
process.env.NODE_PATH = (process.env.NODE_PATH || '')
.split(path.delimiter)
@ -59,9 +63,9 @@ process.env.NODE_PATH = (process.env.NODE_PATH || '')
module.exports = () => {
const raw = {
PORT: process.env.PORT || 3000,
BACKEND_URL: process.env.BACKEND_URL,
NODE_ENV: process.env.NODE_ENV || 'development',
BACKEND_URL: process.env.BACKEND_URL || 'http://localhost:5000',
PORT: process.env.PORT || 3000,
PUBLIC_HOST_URL: process.env.PUBLIC_HOST_URL,
};

View File

@ -4,10 +4,10 @@ import * as path from 'path';
import chalk from 'chalk';
import manifestHelpers from 'express-manifest-helpers';
import * as bodyParser from 'body-parser';
import dotenv from 'dotenv';
import expressWinston from 'express-winston';
import i18nMiddleware from 'i18next-express-middleware';
import '../config/env';
// @ts-ignore
import * as paths from '../config/paths';
import log from './log';
@ -17,8 +17,6 @@ import i18n from './i18n';
process.env.SERVER_SIDE_RENDER = 'true';
const isDev = process.env.NODE_ENV === 'development';
dotenv.config();
const app = express();
// log requests

View File

@ -18,6 +18,7 @@ import i18n from './i18n';
// @ts-ignore
import * as paths from '../config/paths';
import { storeActionsForPath } from './ssrAsync';
const isDev = process.env.NODE_ENV === 'development';
let cachedStats: any;
@ -78,6 +79,7 @@ const chunkExtractFromLoadables = (loadableState: any) =>
const serverRenderer = () => async (req: Request, res: Response) => {
const { store } = configureStore();
await storeActionsForPath(req.url, store);
// i18n
const locale = (req as any).language;

View File

@ -0,0 +1,46 @@
import { Store } from 'redux';
import { fetchProposal } from 'modules/proposals/actions';
import {
fetchUser,
fetchUserCreated,
fetchUserFunded,
fetchUserComments,
} from 'modules/users/actions';
import { extractProposalIdFromUrl } from 'utils/api';
const pathActions = [
{
matcher: /^\/proposals\/(.+)$/,
action: (match: RegExpMatchArray, store: Store) => {
const proposalId = extractProposalIdFromUrl(match[1]);
if (proposalId) {
return store.dispatch<any>(fetchProposal(proposalId));
}
},
},
{
matcher: /^\/profile\/(.+)$/,
action: (match: RegExpMatchArray, store: Store) => {
const userId = match[1];
if (userId) {
return Promise.all([
store.dispatch<any>(fetchUser(userId)),
store.dispatch<any>(fetchUserCreated(userId)),
store.dispatch<any>(fetchUserFunded(userId)),
store.dispatch<any>(fetchUserComments(userId)),
]);
}
},
},
];
export function storeActionsForPath(path: string, store: Store) {
const pathAction = pathActions.find(pa => !!path.match(pa.matcher));
if (pathAction) {
const matches = path.match(pathAction.matcher);
if (matches) {
return pathAction.action(matches, store);
}
}
return Promise.resolve();
}