diff --git a/packages/web/src/pages/_app.tsx b/packages/web/src/pages/_app.tsx index 29c589344..5ad969a2f 100644 --- a/packages/web/src/pages/_app.tsx +++ b/packages/web/src/pages/_app.tsx @@ -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'; @@ -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 = {}; @@ -130,4 +134,4 @@ OliApp.getInitialProps = async appCtx => { return { pageProps }; }; -export default withData(OliApp); +export default withApollo(OliApp); diff --git a/packages/web/src/utils/apollo.tsx b/packages/web/src/utils/apollo.tsx new file mode 100644 index 000000000..6195de0c6 --- /dev/null +++ b/packages/web/src/utils/apollo.tsx @@ -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; + 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 ( + + + + ); + }; + + 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( + , + ); + } 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 = 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 => { + // 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, + ); +}; diff --git a/packages/web/src/utils/helpers.ts b/packages/web/src/utils/helpers.ts index c437cb7e7..932647658 100644 --- a/packages/web/src/utils/helpers.ts +++ b/packages/web/src/utils/helpers.ts @@ -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'; @@ -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'); diff --git a/packages/web/src/utils/initApollo.ts b/packages/web/src/utils/initApollo.ts deleted file mode 100644 index a451a8ee0..000000000 --- a/packages/web/src/utils/initApollo.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - InMemoryCache, - NormalizedCacheObject, - ApolloClient, - ApolloLink, - createHttpLink, -} from '@apollo/client'; -import { setContext } from '@apollo/link-context'; -import { onError } from '@apollo/link-error'; -import fetch from 'isomorphic-unfetch'; - -let apolloClient = null; - -// Polyfill fetch() on the server (used by apollo-client) -if (!process.browser) { - global.fetch = fetch; -} - -function create(initialState, { getToken }) { - 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); - }); - - const httpLink = createHttpLink({ - // TODO: Fix this to work on intranet - uri: 'http://localhost:4000/graphql', - credentials: 'same-origin', - }); - - // To do this with apollo-boost: - // https://github.com/apollographql/apollo-client/issues/3044 - const authLink = setContext((_, { headers }) => { - const token = getToken(); - return { - headers: { - ...headers, - authorization: token ? `Bearer ${token}` : '', - }, - }; - }); - - const cache = new InMemoryCache().restore(initialState || {}); - - return new ApolloClient({ - // Check if apollo-boost would need this for ssrMode - connectToDevTools: process.browser, - // apollo-boost doesn't support ssrMode - // https://github.com/apollographql/apollo-client/issues/3335 - ssrMode: !process.browser, // Disables forceFetch on the server (so queries are only run once) - link: ApolloLink.from([errorLink, authLink, httpLink]), - cache, - }); -} - -export default function initApollo( - initialState, - options, -): ApolloClient { - // 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 (!process.browser) { - return create(initialState, options); - } - - // Reuse client on the client-side - if (!apolloClient) { - apolloClient = create(initialState, options); - } - - return apolloClient; -} diff --git a/packages/web/src/utils/withData.tsx b/packages/web/src/utils/withData.tsx deleted file mode 100644 index 16c538c08..000000000 --- a/packages/web/src/utils/withData.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { - ApolloClient, - ApolloProvider, - NormalizedCacheObject, -} from '@apollo/client'; -import Head from 'next/head'; -import React from 'react'; -import { getDataFromTree } from '@apollo/react-ssr'; - -import { parseCookies } from './helpers'; -import initApollo from './initApollo'; - -// Gets the display name of a JSX component for dev tools -const getDisplayName = ({ displayName, name }) => - displayName || name || 'Unknown'; - -export interface WithDataProps { - apolloState: NormalizedCacheObject; -} - -/** - * SSR is not working after moving to hooks api. We could try to update this - * HoC: https://github.com/vercel/next.js/pull/9516. - * Or try upgrading "@apollo/react-ssr": "^4.0.0-beta.1". - * https://github.com/apollographql/react-apollo/issues/3678#issuecomment-579359439 - * DONE: 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 may be that `loading` is still true after the `data` is fetched: - * https://github.com/apollographql/react-apollo/issues/3678#issuecomment-630075090. - * - * Or try getMarkupFromTree: - * https://github.com/apollographql/react-apollo/issues/3251#issuecomment-513223453 - * - * We should also change it to work for PageComponents instead of App. This way - * we can take advantage of static optimizationi n Next.js version 9. - * https://github.com/vercel/next.js/issues/9503 - */ -export default App => { - return class WithData extends React.Component { - static displayName = `WithData(${getDisplayName(App)})`; - - static async getInitialProps(ctx) { - const { - AppTree, - ctx: { req, res }, - } = ctx; - console.log('WithData.getInitialProps() -> ctx: ', Object.keys(ctx)); - console.log('WithData.getInitialProps() -> router:', { - pathname: ctx.router.pathname, - }); - - // One-time-use apollo client for initial props and rendering (on server) - const apollo = initApollo( - {}, - { getToken: () => parseCookies(req).token }, - ); - ctx.ctx.apolloClient = apollo; - - let appProps = {}; - if (App.getInitialProps) { - appProps = await App.getInitialProps(ctx); - } - console.log('WithData -> appProps: ', Object.keys(appProps)); - - // When redirecting, the response is finished. No point in continuing to render. - if (res && res.finished) { - return {}; - } - - // Run all GraphQL queries in the component tree and extract the resulting data - if (!process.browser) { - try { - // Run all GraphQL queries - const app = ( - - - - ); - console.log('Awaiting getDataFromTree()...'); - await getDataFromTree(app); - } catch (error) { - // Prevent Apollo Client GraphQL errors from crashing SSR. - // Handle them in components via the data.error prop: - // http://dev.apollodata.com/react/api-queries.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's store - const apolloState = apollo.cache.extract(); - console.log('getDataFromTree() finished -> data extracted'); - - return { - apolloState, - ...appProps, - }; - } - - apolloClient: ApolloClient; - - constructor(props) { - super(props); - // Note: Apollo should never be used on the server side beyond the initial - // render within `getInitialProps()` above (since the entire prop tree - // will be initialized there), meaning the below will only ever be - // executed on the client. - this.apolloClient = initApollo(props.apolloState, { - getToken: process.browser - ? () => parseCookies().token - : () => undefined, - }); - } - - render() { - console.log('WithData.render()'); - return ( - - - - ); - } - }; -};