Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix fetching apolloState on SSR #323

Merged
merged 2 commits into from
Jun 19, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/web/src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import React from 'react';

import PageContext, { LoggedInUser } from '../components/PageContext';
import checkLoggedIn from '../utils/checkLoggedIn';
import withData from '../utils/withData';
import { withApollo } from '../utils/apollo';
import { ThemeProvider } from '../components/ThemeContext';
import { pages } from '../pages';

Expand Down Expand Up @@ -112,6 +112,10 @@ OliApp.getInitialProps = async appCtx => {
console.log('OliApp.getInitialProps -> appCtx -> router: ', {
pathname: appCtx.router.pathname,
});
// TODO: Find another way to check logged user to allow Next.js'
// automatic static optimization:
// https://nextjs.org/blog/next-9#automatic-static-optimization
// For now we don't need to show logged user in the home page for example.
const { loggedInUser } = await checkLoggedIn(ctx.apolloClient);
let pageProps = {};

Expand All @@ -130,4 +134,4 @@ OliApp.getInitialProps = async appCtx => {
return { pageProps };
};

export default withData(OliApp);
export default withApollo(OliApp);
279 changes: 279 additions & 0 deletions packages/web/src/utils/apollo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
/**
* This integration with Apollo Client is based on the last version of the
* with-apollo-auth example that was is the Next.js repository.
*
* The difference is we're wrapping the AppType from _app.tsx instead of the
* NextPage. The integration was refactored to allow automatic static
* optimization for pages that don't use apollo:
*
* - https://github.com/vercel/next.js/pull/8556
*
* The example was removed later in this commit:
*
* - https://github.com/vercel/next.js/pull/9516
*
* The specifc file this was based on is the following:
*
* https://github.com/vercel/next.js/blob/caa5347873a7162079e4c66ad0d198df8ac670f3/examples/with-apollo-auth/lib/apollo.js
*
* This is getting the data and sending it through __NEXT_DATA__, but
* the HTML is still being rendered with the loading state on SSR.
*
* The problem is that `loading` is still true after the `data` is fetched:
* https://github.com/apollographql/react-apollo/issues/3678#issuecomment-630075090.
*
* This is certainly because of the beta versions of @apollo/* we're using.
*
* We could try getMarkupFromTree():
* https://github.com/apollographql/react-apollo/issues/3251#issuecomment-513223453
*/
// TODO: Refactor this to wrap NextPage and use automatic static optimization
// https://github.com/vercel/next.js/issues/9503
import http from 'http';
import React from 'react';
import Head from 'next/head';
import cookie, { CookieParseOptions } from 'cookie';
import {
ApolloClient,
ApolloLink,
ApolloProvider,
HttpLink,
InMemoryCache,
NormalizedCacheObject,
} from '@apollo/client';
import { setContext } from '@apollo/link-context';
import { onError } from '@apollo/link-error';
import fetch from 'isomorphic-unfetch';
import {
AppType,
NextComponentType,
AppContextType,
AppInitialProps,
AppPropsType,
} from 'next/dist/next-server/lib/utils';

type WithApolloAppPropsType = AppPropsType & {
apolloClient: ApolloClient<NormalizedCacheObject>;
apolloState: NormalizedCacheObject;
};

type WithApolloAppType = NextComponentType<
AppContextType,
AppInitialProps,
WithApolloAppPropsType
>;

/**
* Creates and provides the apolloContext
* to a Next.js AppTree. Use it by wrapping
* your App (_app.tsx) via HOC pattern.
*/
export const withApollo = (AppComponent: AppType) => {
const WithApollo: WithApolloAppType = appProps => {
console.log('WithApollo rendering...');

// const { apolloClient, apolloState, ...pageProps } = pagePropsOriginal;
const { apolloClient, apolloState } = appProps;

const client = React.useMemo(() => {
// We pass in the apolloClient directly when using getDataFromTree
if (apolloClient) {
return apolloClient;
}

// Otherwise initClient using apolloState
return initApolloClient(apolloState, {
getToken: () => {
return parseCookies().token;
},
});
}, []);

return (
<ApolloProvider client={client}>
<AppComponent {...appProps} />
</ApolloProvider>
);
};

if (process.env.NODE_ENV !== 'production') {
// Find correct display name
const displayName =
AppComponent.displayName || AppComponent.name || 'Unknown';

// Set correct display name for devtools
WithApollo.displayName = `withApollo(${displayName})`;
}

WithApollo.getInitialProps = async appCtx => {
// If we were wrapping a NextPage...
// const { AppTree, req, res } = pageCtx;
// ...instead of the AppType (_app.tsx)
const {
AppTree,
ctx: { req, res },
} = appCtx;
console.log('WithApollo.getInitialProps() -> ctx: ', Object.keys(appCtx));
console.log('WithApollo.getInitialProps() -> router:', {
pathname: appCtx.router.pathname,
});

// Run all GraphQL queries in the component tree
// and extract the resulting data
const apolloClient = initApolloClient(
{},
{
getToken: () => parseCookies(req).token,
},
);
appCtx.ctx.apolloClient = apolloClient;
// For NextPage:
// ctx.apolloClient = apolloClient;

const appProps = AppComponent.getInitialProps
? await AppComponent.getInitialProps(appCtx)
: { pageProps: {} };

console.log('WithApollo -> appProps: ', Object.keys(appProps));

if (res && res.finished) {
// When redirecting, the response is finished.
// No point in continuing to render
return { pageProps: {} };
}

// Get apolloState on the server (needed for ssr)
if (typeof window === 'undefined') {
try {
// Run all GraphQL queries
const { getDataFromTree } = await import('@apollo/react-ssr');
// When wrapping a NextPage this should also be different:
// https://github.com/vercel/next.js/blob/caa5347873a7162079e4c66ad0d198df8ac670f3/examples/with-apollo-auth/lib/apollo.js#L82-L86
await getDataFromTree(
<AppTree {...appProps} apolloClient={apolloClient} />,
);
} catch (error) {
// Prevent Apollo Client GraphQL errors from crashing SSR.
// Handle them in components via the data.error prop:
// https://www.apollographql.com/docs/react/api/react-apollo.html#graphql-query-data-error
console.error('Error while running `getDataFromTree`', error);
}

// getDataFromTree does not call componentWillUnmount
// head side effect therefore need to be cleared manually
Head.rewind();
}

// Extract query data from the Apollo store
const apolloState = apolloClient.cache.extract();

return {
...appProps,
apolloState,
};
};

return WithApollo;
};

let apolloClient: ApolloClient<NormalizedCacheObject> = null;

interface ApolloClientCreateOptions {
getToken: () => string;
}

/**
* Always creates a new apollo client on the server.
* Creates or reuses apollo client in the browser.
*/
const initApolloClient = (
initialState: NormalizedCacheObject,
options: ApolloClientCreateOptions,
): ApolloClient<NormalizedCacheObject> => {
// Make sure to create a new client for every server-side request so that data
// isn't shared between connections (which would be bad)
if (typeof window === 'undefined') {
return createApolloClient(initialState, options);
}

// Reuse client on the client-side
if (!apolloClient) {
apolloClient = createApolloClient(initialState, options);
}

return apolloClient;
};

/**
* Creates and configures the ApolloClient
*/
const createApolloClient = (
initialState: NormalizedCacheObject = {},
{ getToken }: ApolloClientCreateOptions,
) => {
const fetchOptions = {};

// If you are using a https_proxy, add fetchOptions with 'https-proxy-agent' agent instance
// 'https-proxy-agent' is required here because it's a sever-side only module
// if (typeof window === 'undefined') {
// if (process.env.https_proxy) {
// fetchOptions.agent = new (require('https-proxy-agent'))(
// process.env.https_proxy
// )
// }
// }

const httpLink = new HttpLink({
// TODO: Fix this to work on intranet
uri: 'http://localhost:4000/graphql', // Server URL (must be absolute)
credentials: 'same-origin',
fetch,
fetchOptions,
});

const authLink = setContext((_, { headers }) => {
const token = getToken();
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});

const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.map(({ message, locations, path }) =>
console.log(
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`,
),
);
}
if (networkError) console.log(`[Network error]: `, networkError);
});

// Check out https://github.com/zeit/next.js/pull/4611 if you want
// to use the AWSAppSyncClient
const isBrowser = typeof window !== 'undefined';
return new ApolloClient({
connectToDevTools: isBrowser,
// Disables forceFetch on the server (so queries are only run once)
ssrMode: !isBrowser,
link: ApolloLink.from([errorLink, authLink, httpLink]),
cache: new InMemoryCache().restore(initialState),
});
};

/**
* Cookie parser that works on the
* server and on the client
*/
const parseCookies = (
req?: http.IncomingMessage,
options?: CookieParseOptions,
) => {
return cookie.parse(
req ? req.headers.cookie || '' : document.cookie,
options,
);
};
5 changes: 0 additions & 5 deletions packages/web/src/utils/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
// import warning from 'warning';
import http from 'http';
import cookie from 'cookie';
import camelCase from 'lodash/camelCase';
import upperFirst from 'lodash/upperFirst';

Expand Down Expand Up @@ -30,9 +28,6 @@ export function pageToTitle(page) {
return titleize(name);
}

export const parseCookies = (req?: http.IncomingMessage, options = {}) =>
cookie.parse(req ? req.headers.cookie || '' : document.cookie, options);

export function getCookie(name: string) {
const regex = new RegExp(`(?:(?:^|.*;*)${name}*=*([^;]*).*$)|^.*$`);
return document.cookie.replace(regex, '$1');
Expand Down
Loading