lfades / next-with-apollo

Apollo HOC for Next.js
MIT License
765 stars 79 forks source link

Support hooks #74

Closed nealoke closed 4 years ago

nealoke commented 5 years ago

As of 2 hours ago official support for hooks by apollo has landed in version 3.0.0.. Would love to use this together with this HOC correctly. πŸ˜„

lfades commented 5 years ago

Let's hope I don't get multiple bugs this time, I'm also waiting on a breaking change in Next.js for the Apollo implementations, then I guess I'll do another major.

nealoke commented 5 years ago

It is actually working fine πŸš€ , the only issue is when the react-apollo package is not installed. A fix would be nice as apollo is now splitting into multiple packages for lighter weight?

mikestecker commented 5 years ago

As soon as I upgraded react-apollo to 3.0.0 I started getting the following error:

Invariant Violation: Could not find "client" in the context or passed in as a prop. Wrap the root component in an <ApolloProvider>, or pass an ApolloClient instance in via props.

Assuming it might be related? Rolling back react-apollo to ^2.5.8 and the error went away

nealoke commented 5 years ago

@mikestecker don't forget to use the ApolloProvider from the @apollo/react-hooks in stead of the react-apollo one.

mikestecker commented 5 years ago

@nealoke Thanks, I've tried that but I started to get even more errors.

In the browser I'm seeing: Could not find "client" in the context or passed in as a prop. Wrap the root component in an <ApolloProvider>, or pass an ApolloClient instance in via props.

And in my terminal I'm getting Error: Not allowed by CORS errors now like crazy which I've never had before trying to implement hooks for some strange reason. My backend is using GraphQL-Yoga and Prisma if that matters.

Also, looks like I have to go back and migrate all my compose code too

markplindsay commented 5 years ago

@mikestecker, I am very new to both Next.js and Apollo/GraphQL, but I've got useQuery working with SSR and everything. This might be a too-simple example, but maybe it will help you out:

// helpers/withApollo.ts
import withApollo from 'next-with-apollo'
import ApolloClient, { InMemoryCache } from 'apollo-boost'

const GRAPHQL_URL = 'https://dog-graphql-api.glitch.me/graphql'

export default withApollo(
  ({ initialState }) =>
    new ApolloClient({
      cache: new InMemoryCache().restore(initialState || {}),
      uri: GRAPHQL_URL,
    })
)
// pages/_app.tsx
import { ApolloClient } from 'apollo-boost'
import { default as NextApp, createUrl } from 'next/app'
import { Router } from 'next/router'
import {
  loadGetInitialProps,
  AppContextType,
  AppInitialProps,
} from 'next-server/dist/lib/utils'
import { ApolloProvider } from '@apollo/react-hooks'
import Container from 'components/Container'
import withApollo from 'helpers/withApollo'

interface Props {
  apollo: ApolloClient<{}>
}

class App extends NextApp<Props> {
  static async getInitialProps({
    Component,
    ctx,
  }: AppContextType<Router>): Promise<AppInitialProps> {
    let pageProps = await loadGetInitialProps(Component, ctx)
    return { pageProps }
  }

  render() {
    const url = createUrl(this.props.router)
    return (
      <ApolloProvider client={this.props.apollo}>
        <Container
          Component={this.props.Component}
          pageProps={this.props.pageProps}
          url={url}
        />
      </ApolloProvider>
    )
  }
}

export default withApollo(App)
// pages/index.tsx
import { useQuery } from '@apollo/react-hooks'
import gql from 'graphql-tag'
import Head from 'next/head'

export const dogsQuery = gql`
  {
    dogs {
      id
      breed
    }
  }
`

const Index = () => {
  const { data, error, loading } = useQuery(dogsQuery)
  if (error) {
    return <div>Error</div>
  }
  if (loading) {
    return <div>Loading</div>
  }
  return (
    <>
      <Head>
        <title>Dogs</title>
      </Head>
      <ul>
        {data.dogs.map((dog: any, i: number) => (
          <li key={dog.id}>
            <div>
              <span>{i + 1}. </span>
              <span>{dog.breed}</span>
            </div>
          </li>
          ))}
      </ul>
    </>
  )
}

export default Index
  // package.json
  "dependencies": {
    "apollo-boost": "^0.4.3",
    "graphql": "^14.4.2",
    "next": "^9.0.0",
    "next-with-apollo": "^4.2.0",
    "react": "^16.8.6",
    "react-apollo": "^3.0.0",
    "react-dom": "^16.8.6"
  },

I'm glad I started using this just as hooks support came out, because the syntax is so much nicer than using the Query component.

mikestecker commented 5 years ago

@markplindsay thanks for the code sample, I don't know what it is but I just disabled my "whitelist" approach to CORS which solved the issue. Something with the changes to react-apollo was causing an origin mismatch somehow, somewhere. No idea. I don't know enough about CORS to figure it out.

I had this on my server side:

const whitelist = [process.env.FRONTEND_URL, process.env.ADMIN_URL];
server.express.use(
  "/*",
  cors({
    credentials: true,
    origin: function(origin, callback) {
      if (whitelist.indexOf(origin) !== -1) {
        callback(null, true);
      } else {
        callback(new Error("Not allowed by CORS"));
      }
    }
  })
);

Which was having issues. I changed that entire origin function block to simply be origin: true and all my errors went away.

Apologies for hijacking the comment thread here with what turned out to be an unrelated problem!

lfades commented 5 years ago

@markplindsay Congrats on making it work, I'll update the package once a breaking change in Next.js is released, then I'll do a major updating the package and removing react-apollo too

nealoke commented 5 years ago

@mikestecker Have a look at the docs for the cors origin. Basically if you want to allow everything just set it to origin: true. If a request comes in with origin test.com it will respond with allowed origins: test.com.

S0PEX commented 5 years ago

Hey, guys, I am just trying to get this working. I have set up this as described in the readme but my application fails with the invalid hooks error message. Image of the error My react version matches the dom version.

yarn list v1.17.3
β”œβ”€ @apollo/react-common@3.0.0
β”œβ”€ @apollo/react-components@3.0.0
β”œβ”€ @apollo/react-hoc@3.0.0
β”œβ”€ @apollo/react-hooks@3.0.0
β”œβ”€ @apollo/react-ssr@3.0.0
β”œβ”€ @babel/helper-builder-react-jsx@7.3.0
β”œβ”€ @babel/plugin-transform-react-display-name@7.2.0
β”œβ”€ @babel/plugin-transform-react-jsx-self@7.2.0
β”œβ”€ @babel/plugin-transform-react-jsx-source@7.5.0
β”œβ”€ @babel/plugin-transform-react-jsx@7.3.0
β”œβ”€ @babel/preset-react@7.0.0
β”œβ”€ @semantic-ui-react/event-stack@3.1.0
β”œβ”€ @types/react-dom@16.8.5
β”œβ”€ @types/react@16.9.1
β”œβ”€ babel-plugin-react-require@3.0.0
β”œβ”€ babel-plugin-transform-react-remove-prop-types@0.4.24
β”œβ”€ create-react-context@0.2.3
β”œβ”€ hoist-non-react-statics@3.3.0
β”œβ”€ next-server@9.0.3
β”‚  └─ react-is@16.8.6
β”œβ”€ next@9.0.3
β”‚  └─ react-is@16.8.6
β”œβ”€ react-apollo@3.0.0
β”œβ”€ react-dom@16.9.0
β”œβ”€ react-error-overlay@5.1.6
β”œβ”€ react-fast-compare@2.0.4
β”œβ”€ react-is@16.9.0
β”œβ”€ react-popper@1.3.3
β”‚  └─ create-react-context@0.2.2
β”œβ”€ react@16.9.0
└─ semantic-ui-react@0.87.3

This is my LoginForm :

import * as React from 'react';
import * as Yup from 'yup';
import { Formik, FormikActions } from 'formik';
import { Form, Button } from 'semantic-ui-react';
import { gql } from 'apollo-boost';
import { useMutation } from '@apollo/react-hooks';

const LoginFormSchema = Yup.object().shape({
  username: Yup.string()
    .min(2, 'Too Short!')
    .max(50, 'Too Long!')
    .required('Username is required !'),
  password: Yup.string()
    .min(2, 'Too Short!')
    .max(50, 'Too Long!')
    .required('Password is required !')
});
const DO_LOGIN = gql`
  mutation DoLogin($cred: LoginCredentialsInput!) {
    login(credentials: $cred)
  }
`;
interface Props {
  loginMethod(values: any, actions: FormikActions<any>): void;
}
export const LoginForm: React.FunctionComponent<Props> = ({ loginMethod }) => (
  <div>
    <h1>Login</h1>
    <Formik
      initialValues={{
        username: '',
        password: ''
      }}
      validationSchema={LoginFormSchema}
      onSubmit={(values, actions: FormikActions<any>) => {
        const [login, { data }] = useMutation(DO_LOGIN);
        login({ variables: { cred: values } });
        console.log(data);
        loginMethod(values, actions);
      }}
    >
      {({ errors, touched, handleChange, handleSubmit, isSubmitting }) => (
        <Form
          size='small'
          onSubmit={handleSubmit}
          loading={isSubmitting}
          widths='equal'
        >
          <Form.Input
            name='username'
            onChange={handleChange}
            error={
              errors.username && touched.username
                ? {
                    content: errors.username,
                    pointing: 'below'
                  }
                : null
            }
            icon='user'
            iconPosition='left'
            placeholder='Username'
            fluid
            autoFocus
          />
          <Form.Input
            name='password'
            type='password'
            onChange={handleChange}
            error={
              errors.password && touched.password
                ? {
                    content: errors.password,
                    pointing: 'below'
                  }
                : null
            }
            icon='lock'
            iconPosition='left'
            placeholder='Password'
            fluid
          />
          <Button type='submit'>Submit</Button>
        </Form>
      )}
    </Formik>
  </div>
);

Is there something obvious I am doing wrong quite new to react, apollo and would appreciate any hints. The source code is also available at https://github.com/S0PEX/Content-Web-App Regards Artur

Corjen commented 5 years ago

@S0PEX You cant use a hook inside an event handler(onSubmit). You should move the useMutation hook to the top of your component.

S0PEX commented 5 years ago

@Corjen Thanks that did the trick so do I always use the mutation and query from the react hooks or from the apollo library or is there no difference? Because somebody mentioned that you should use react hooks over apollo react.

mikestecker commented 5 years ago

@mikestecker Have a look at the docs for the cors origin. Basically if you want to allow everything just set it to origin: true. If a request comes in with origin test.com it will respond with allowed origins: test.com.

@nealoke I double checked everything and ran some logging on the origin and it's coming back as undefined on the some hits to the server and then it would come through correctly on others. Seems entirely inconsistent. Probably an issue on my frontend code with Apollo?

Screenshot of my console.log:

Screenshot 2019-08-11 11 30 17

I logged both the origin and my whitelist that it should be checking against.

Here's my frontend code:

./pages/_app.js:

import App, { Container } from "next/app";
import Root from "../components/Root";
import { ApolloProvider } from "@apollo/react-hooks";
import withData from "../lib/withData";

class MyApp extends App {
  // gets all page properties and queries before loading to pass along
  static async getInitialProps({ Component, ctx }) {
    let pageProps = {};
    if (Component.getInitialProps) {
      pageProps = await Component.getInitialProps(ctx);
    }
    // this exposes the query to the user
    pageProps.query = ctx.query;
    return { pageProps };
  }

  render() {
    const { Component, apollo, pageProps } = this.props;

    return (
      <Container>
        <ApolloProvider client={apollo}>
          <Root client={apollo}>
            <Component client={apollo} {...pageProps} />
          </Root>
        </ApolloProvider>
      </Container>
    );
  }
}

export default withData(MyApp);

./lib/withData.js:

import withApollo from "next-with-apollo";
import { ApolloClient } from "apollo-client";
import { InMemoryCache } from "apollo-cache-inmemory";
import { HttpLink } from "apollo-link-http";
import * as ws from "ws";
import { WebSocketLink } from "apollo-link-ws";
import { getMainDefinition } from "apollo-utilities";
import { onError } from "apollo-link-error";
import { ApolloLink, Observable, split } from "apollo-link";
import { RetryLink } from "apollo-link-retry";
import { createUploadLink } from "apollo-upload-client";
import { endpoint, wsEndpoint } from "../config";

const request = (operation, headers) => {
  operation.setContext({
    fetchOptions: {
      credentials: "include"
    },
    headers
  });
};

function createClient({ ctx, headers, initialState }) {
  const httpLink = new HttpLink({
    uri: process.env.NODE_ENV === "development" ? endpoint : endpoint
  });
  const wsLink = process.browser
    ? new WebSocketLink({
        uri: process.env.NODE_ENV === "development" ? wsEndpoint : wsEndpoint,
        options: {
          reconnect: true
        }
      })
    : () => {
        console.log("SSR");
      };

  return new ApolloClient({
    link: ApolloLink.from([
      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}`);
        if (networkError) console.log("[Network error]: ", networkError);
      }),
      new ApolloLink(
        (operation, forward) =>
          new Observable(observer => {
            let handle;
            Promise.resolve(operation)
              .then(oper => request(oper, headers))
              .then(() => {
                handle = forward(operation).subscribe({
                  next: observer.next.bind(observer),
                  error: observer.error.bind(observer),
                  complete: observer.complete.bind(observer)
                });
              })
              .catch(observer.error.bind(observer));

            return () => {
              if (handle) handle.unsubscribe();
            };
          })
      ),
      new RetryLink().split(
        ({ query }) => {
          const definition = getMainDefinition(query);
          return (
            definition.kind === "OperationDefinition" &&
            definition.operation === "subscription"
          );
        },
        wsLink,
        // httpLink,
        // httpLink breaks uploader
        createUploadLink({
          uri: process.env.NODE_ENV === "development" ? endpoint : endpoint
        })
      )
    ]),
    cache: new InMemoryCache().restore(initialState || {})
  });
}

export default withApollo(createClient);

and on the backend, here's the possible relevant code from graphql-yoga:

./createServer.js:

const { GraphQLServer, PubSub } = require("graphql-yoga");
const depthLimit = require("graphql-depth-limit");
const Mutation = require("./resolvers/Mutation");
const Query = require("./resolvers/Query");
const Subscription = require("./resolvers/Subscription");
const Conversation = require("./resolvers/Conversation");
const db = require("./db");

const pubsub = new PubSub();

// Create the GraphQL Yoga Server

function createServer() {
  return new GraphQLServer({
    typeDefs: "src/schema.graphql",
    resolvers: {
      Mutation,
      Query,
      Subscription,
      Conversation
    },
    resolverValidationOptions: {
      requireResolversForResolveType: false
    },
    // https://github.com/stems/graphql-depth-limit
    // https://blog.apollographql.com/securing-your-graphql-api-from-malicious-queries-16130a324a6b
    validationRules: [depthLimit(10)],
    uploads: {
      // Limits here should be stricter than config for surrounding
      // infrastructure such as Nginx so errors can be handled elegantly by
      // graphql-upload:
      // https://github.com/jaydenseric/graphql-upload#type-uploadoptions
      maxFileSize: 10000000, // 10 MB
      maxFiles: 20
    },
    context: (req, connection) => ({
      ...req,
      pubsub,
      db
    })
  });
}

module.exports = createServer;

index.js:

require("dotenv").config({ path: ".env" });
const cookie = require("cookie");
const cookieParser = require("cookie-parser");
const cors = require("cors");
const jwt = require("jsonwebtoken");
const expressip = require("express-ip");
const helmet = require("helmet");
const compression = require("compression");
const { S3 } = require("aws-sdk");

const logger = require("./utils/logger");

const createServer = require("./createServer");
const db = require("./db");

const server = createServer();

const s3client = new S3({
  accessKeyId: process.env.S3_KEY,
  secretAccessKey: process.env.S3_SECRET,
  params: {
    Bucket: process.env.S3_BUCKET
  }
});

server.express.use(helmet());
server.express.use(compression());
server.express.use(cookieParser());
server.express.use(expressip().getIpInfoMiddleware);

// get the users IP information
// note: doesn't work with localhost
server.express.use((req, res, next) => {
  const userIp = req.ipInfo;
  if (userIp) {
    req.userIp = userIp;
  }
  next();
});

// decode the JWT so we can ge the user ID on each request
server.express.use((req, res, next) => {
  const { token } = req.cookies;
  if (token) {
    const { userId } = jwt.verify(token, process.env.APP_SECRET);
    // put the userId onto the request for future requests to access
    req.userId = userId;
  }
  next();
});

// create a middleware that populates the user on each request
server.express.use(async (req, res, next) => {
  // if they aren't logged in, skip this
  if (!req.userId) return next();
  const user = await db.query.user(
    { where: { id: req.userId } },
    "{ id, email, emailMask, emailVerified, role, permissions, account { accountType } }"
  );
  req.user = user;
  next();
});

const whitelist = [process.env.FRONTEND_URL, process.env.ADMIN_URL];

// TODO: FIGURE OUT CORS ISSUES
server.express.use(
  "/*",
  cors({
    credentials: true,
     origin: function(origin, callback) {
       console.log("origin", origin);
       console.log("whitelist", whitelist);
       if (whitelist.indexOf(origin) !== -1) {
         // callback(null, true);
       } else {
         // callback(new Error("Not allowed by CORS"));
       }
       // allow everything to pass for now while I figure out the issue
       callback(null, true);
     }
  })
); // allow cors

// if (process.env.NODE_ENV === 'development') server.express.use(logger);

server.start(
  {
    // TODO: FIGURE OUT CORS ISSUES
    cors: {
      credentials: true,
      origin: function(origin, callback) {
        console.log("origin", origin);
        console.log("whitelist", whitelist);
        if (whitelist.indexOf(origin) !== -1) {
          // callback(null, true);
        } else {
          // callback(new Error("Not allowed by CORS"));
        }
        // allow everything to pass for now while I figure out the issue
        callback(null, true);
      }
    },
    subscriptions: {
      onConnect: async (connectionParams, webSocket) => {
        console.log("Websocket CONNECTED");
        const header = webSocket.upgradeReq.headers.cookie;
        const { token } = cookie.parse(header);
        try {
          const promise = new Promise((resolve, reject) => {
            const { userId } = jwt.verify(token, process.env.APP_SECRET);
            resolve(userId);
          });
          const user = await promise;
          return user;
        } catch (err) {
          throw new Error(err);
        }
      },
      onDisconnect: () => console.log("Websocket DISCONNECTED")
    }
  },
  deets => {
    console.log(
      `πŸš€ Backend is now running on port http:/localhost:${deets.port}`
    );
  }
);
dacioromero commented 5 years ago

I've created #77 which uses @apollo/react-ssr instead of react-apollo.

nealoke commented 5 years ago

@mikestecker hmm weird, below is my setup as it might help.

Versions

"@apollo/react-hooks": "^3.0.0",
"apollo-boost": "^0.4.3",
"graphql": "^14.4.2",
"next": "^9.0.3",
"next-with-apollo": "^4.2.0",
"react": "^16.8.6",
"react-apollo": "^3.0.0",
"react-dom": "^16.8.6"

server.js (back-end)

import dotenv from "dotenv";
import express from "express";
import cors from "cors";
import bodyParser from "body-parser";
import cookieParser from "cookie-parser";
import jwt from "jsonwebtoken";

import apolloServer from "./apolloServer";

dotenv.config();

export const createServer = () => {
    /* ----------  Config  ----------*/
    const port = process.env.PORT || 7000;
    const app = express();
    app.disable("x-powered-by");

    /* ----------  Middleware  ----------*/
    // CORS
    const corsSettings = { credentials: true, origin: true };
    app.use(cors(corsSettings));

    // Body
    app.use(bodyParser.json());

    // Cookies
    app.use(cookieParser());

    /* ----------  Routes  ----------*/
    // app.use(...);
    apolloServer.applyMiddleware({ path: "/graphql", cors: corsSettings, app });

    // Listen
    return app.listen(port, () => console.log(`API listening on port ${port}!`));
};

withData HOC for integrating apollo in next.js

import withApollo from "next-with-apollo";
import ApolloClient, { InMemoryCache } from "apollo-boost";

function createClient({ initialState, headers }) {
    return new ApolloClient({
        uri: "http://localhost:7000/graphql",
        cache: new InMemoryCache().restore(initialState || {}),
        request: (operation) => {
            operation.setContext({
                fetchOptions: {
                    credentials: "include"
                },
                headers
            });
        }
    });
}

export default withApollo(createClient);

_app.js

import React from "react";
import App, { Container } from "next/app";
import { ApolloProvider } from "@apollo/react-hooks";

import withData from "../HOC/withData";

class MyApp extends App {
    static async getInitialProps({ Component, ctx }) {
        let pageProps = {};

        if (Component.getInitialProps) {
            pageProps = await Component.getInitialProps(ctx);
        }

        return { pageProps };
    }

    render() {
        const { Component, apollo, pageProps } = this.props;

        return (
            <Container>
                <ApolloProvider client={apollo}>
                    <Component {...pageProps} />
                </ApolloProvider>
            </Container>
        );
    }
}

export default withData(MyApp);
adamsoffer commented 5 years ago

@lfades any plans for a major release to support hooks again?

nealoke commented 5 years ago

@adamsoffer I don't understand why people are getting the feeling that it is not supported at the moment. I thought this too but the comment above you (my setup) it works 100% with hooks and no issues or bugs πŸ˜„.

adamsoffer commented 5 years ago

@nealoke hm I’l try it, but there’s no hook support in this package so I’m perplexed how that might work. The author published hooks support than reverted because he wanted to wait for apollo hooks to come out of beta which it has been for quite a few weeks now.

mikestecker commented 5 years ago

@mikestecker hmm weird, below is my setup as it might help.

@nealoke Thanks. After some more digging I think I was able to get my issue resolved.

lfades commented 5 years ago

@adamsoffer Right, the hooks update wasn't working in my example projects, I've been reviewing the example packages in Next.js that use Apollo, I certainly believe that the implementation is becoming way easier and this package may no longer be required but just some lines of code (I'm in the Next.js team)

Also there are good code optimizations that can be done by just including the implementation, once I have a good insight of how it's going to look like, I'll update the package again and maybe recommend a custom implementation instead. getDataFromTree is no longer working as before, e.g using it in the browser doesn't really works so the configs in my package for it are not very useful, apollo-boost will be removed and the apollo client packages seem to be going through a refactor so maybe the implementation of Apollo (If my wishes become true) will be even better.

If you're using this package and need hooks, this is the implementation that I currently recommend: with-apollo/lib/apollo.js

adamsoffer commented 5 years ago

@lfades funny I came to the same conclusion this week and stopped using a package altogether. Thanks!

wouterds commented 5 years ago

@lfades Don't like that you need to wrap every page with that HOC instead of just App once with the implementation you're referencing. Edit: I just upgraded from Next.js 8 to 9 and I already see why they recommend wrapping each page, still it's not really convenient if you have a large application where a lot of pages need graphql imho.

lfades commented 5 years ago

@wouterds Because you shouldn't need to add Apollo to all pages unless they all should have it, and when your application grows you'll start finding exceptions