dotansimha / graphql-code-generator

A tool for generating code based on a GraphQL schema and GraphQL operations (query/mutation/subscription), with flexible support for custom plugins.
https://the-guild.dev/graphql/codegen/
MIT License
10.81k stars 1.32k forks source link

Remove need for babel or swc plugin on client preset for reducing bundle size/code splitting #9988

Open CaptainN opened 4 months ago

CaptainN commented 4 months ago

Is your feature request related to a problem? Please describe.

Yes, it's related to a problem. In a site with a large number of client side queries, we get a very large graphq.ts bundle, just to support types. These types should not add ANY weight to the site.

Describe the solution you'd like

I'd like for the generated graphq.ts file to simply use a different format, so it doesn't generate this giant blob.

Describe alternatives you've considered

I can manually edit this thing so that it generates very minimal code like so:

import * as types from "./graphql";

type QueryMap = {
  "query GetUser($id: ID!) { user(id: $id) { name } }": typeof types.GetUserDocument,
  "query GetUsername($id: ID!) { user(id: $id) { username } }": typeof types.GetUsernameDocument,
};

// Overloaded function signatures
export function graphql<TSource extends keyof QueryMap>(source: TSource): QueryMap[TSource];
export function graphql(source: string): unknown;

// Function implementation
export function graphql(source: string) {
  return source;
}

The effort to do this was simple - I used a multi-cursor selection to grab the types, and just pasted them as the return type for the function interfaces, then again for the union type in the graphql function implementation. Easy peasy - should be similarly easy to implement in the code gen.

Is your feature request related to a problem? Please describe.

Yes, it's related to the bloat you get from including 2 copies of largish strings, which can't be code-split with tree shaking.

The solution doesn't present any problem, but the heuristics are different. In this code, it simply passes the string defined by the developer from their graphql file, back out. I see very little downside from doing this, especially if your build pipeline is set up to always regenerate the types. But it is different. In the current implementation, the graphql function returns the result of a lookup in a hash table. but the effects should generally be the same - the string you get back from that hash tables, is the same string you passed the function. In the new implementation, it just isn't in the bundle twice, and isn't in the bundle in a way that can't be tree shaken. I see mostly upside, and very little downside (or no downside) here.

AND - I no longer need to use a babel or SWC plugin, which I can't get to work anyway... (And you guys don't need to maintain them any more, or deal with tickets like these...)

It's pure gravy.

Update:

The original example I provided didn't quite do the same thing as the generated code currently does - this one should.

n1ru4l commented 4 months ago

By doing this globally referenced fragments could no longer be used within the GraphQL operation strings.

CaptainN commented 4 months ago

Hmm, I didn't consider fragments. Can you show me an example? I'd like to see if I can work out a pattern that would support that.

n1ru4l commented 4 months ago

@CaptainN https://github.com/dotansimha/graphql-code-generator/blob/c720b1b86211ec6f6248b4e54a2813f56e3904b1/examples/react/apollo-client/src/App.tsx#L8-L18

CaptainN commented 4 months ago

I added a global fragment to my playground, and ran the generator - it doesn't look like it does anything all that different from including any other query. It doesn't seem like anything else uses the documents object, which is the thing I want to get rid of. I think this would still work as expected:

import * as types from "./graphql";

type QueryMap = {
  "fragment UserFields on User { name }": typeof types.UserFieldsFragmentDoc
  "query GetUser($id: ID!) { user(id: $id) { ...UserFields } }": typeof types.GetUserDocument,
  "query GetUsername($id: ID!) { user(id: $id) { username } }": typeof types.GetUsernameDocument,
};

// Overloaded function signatures
export function graphql<TSource extends keyof QueryMap>(source: TSource): QueryMap[TSource];
export function graphql(source: string): unknown;

// Function implementation
export function graphql(source: string) {
  return source;
}

Am I missing some other type of output?

n1ru4l commented 4 months ago

Yeah, if the graphql function only returns the parameter passed to it:

export function graphql(source: string) {
  return source;
}

That string does not contain the fragment definition, thus when the string is sent to the server, the GraphQL execution will fail.

In addition to that, features like persisted documents will no longer work without the lookup map/babel/swc plugin.

CaptainN commented 4 months ago

Got it. So you still need some kind of runtime lookup system, at least for fragments.

it's just that the map is huge, and the babel/swc plugins either don't work at all (swc) in nextjs, or don't seem to do much even if you get it to work (babel) in nextjs

(And SWC produces smaller chunks - so you actually overall lose budget if you use babel vs swc in next.js - even if I can get the graphql map size down to essentially 0)

n1ru4l commented 4 months ago

The Babel plugin works perfectly for us. SWC has known issues.

We are happy for suggestions on alternative approaches, but right now I am afraid the lookup map (by default) is the only solution that does not force people to use the plugins.

CaptainN commented 4 months ago

Heck, I'd be happy with a solution that does what I've proposed, maybe hidden behind a setting in codegen.ts, even if it breaks global Fragments.

To me, this isn't terrible:

import { graphql } from "data/types/gql";
import ReportFieldsFragment from "data/fragments/ReportFieldFragment";

export default graphql(`
  ${ReportFieldsFragment}
  query reports($order: [Core_EditionSortInput!]) {
    core_reports(order: $order) {
      ...ReportFields
    }
  }
`);

I don't use persisted queries (without APQ anyway), so that's fine.

n1ru4l commented 4 months ago

We could consider passing the fragments as an array parameter to the graphql function, then the graphql function could just concatenate the strings. 🤔

export default graphql(`
  query reports($order: [Core_EditionSortInput!]) {
    core_reports(order: $order) {
      ...ReportFields
    }
  }
`, [ReportFieldsFragment]);

But, this could then cause issues where the same fragment is printed twice in the operation sent to the server because it was referenced within two or more fragments referenced by a query/mutation/subscription operation. That would open a whole new deduplication rabbit-hole.

CaptainN commented 4 months ago

You could dedupe that array pretty easily. What I'm wondering is if there is some pattern on the other side (in gql.ts) we could use to insert the same (deduped) array, without too much overhead. Even something like a hybrid solution, where global fragments are defined the old way, and queries are defined the new way. I'll play with it more later.

CaptainN commented 4 months ago

Had to teach some karate classes, I'm back. I actually think your array solution would work nicely. There shouldn't be deduplication issues - if the Fragment is in the array once, it would only be included in the query package once. Should be pretty straight forward. Similar to the way I did it, without the use of template strings.

Function Implementation:

export function graphql(source: string, fragments: string[] = null) {
  if (Array.isArray(fragments) && fragments.length > 0) {
    return [...fragments, source].join("\n");
  } else {
    return source;
  }
}
n1ru4l commented 4 months ago

@CaptainN this solution won't work if a fragment is referenced by multiple fragments. You will end up with duplicated fragment definitions in the document string, which then will be rejected by the GraphQL server.

const A = graphql(`
  fragment A on Query {
    a
  }
`)

const B = graphql(`
  fragment B on Query {
    ...A
  }
`, [A])

const Query = graphql(`
  query {
    ...A
    ...B
  }
`, [A, B])

Also, you will get no typescript compiler/codegen error if a fragment was not passed, but is required as it is used within that operation document.

CaptainN commented 4 months ago

Ah, thats interesting. We'd probably need to complicate the API to solve for that - like adding an additional method for registering fragments. But that's getting complicated:

const A = graphql.fragment(`
  fragment A on Query {
    a
  }
`)

const B = graphql.fragment(`
  fragment B on Query {
    ...A
  }
`, [A])

const Query = graphql(`
  query {
    ...A
    ...B
  }
`, [A, B])

// implementation (ignoring typescript validation for the moment)
const fragStore = [];
function getFragmentsFromDeps(deps: string[]) {
  return fragStore
      .filter(frag => deps.includes(frag.fragAndDeps))
      .map(frag => frag.fragment)
}

export function graphql(source: string, fragments: string[] = null) {
  if (Array.isArray(fragments) && fragments.length > 0) {
    const frags = getFragmentsFromDeps(fragments);
    // dedupe frags, and combine with source
    return [...new Set(frags), source].join("\n");
  } else {
    return source;
  }
}

graphql.fragments = (fragment: string, deps: string[] = null) => {
  const fragAndDeps = (Array.isArray(deps)) 
    ? [...new Set(getFragmentsFromDeps(deps)), fragment].join("\n")
    : fragment;
  fragStore.push({ fragment, deps, fragAndDeps });
  return fragAndDeps; // I assume this needs to return the aggregated string for type gen to work
}

I didn't test any of that (there may be some cases where we need to recurse on the deps array - I'm not sure, would need to write some unit tests), but the idea would be to store enough info about the registered fragment, that we can reconstruct the correct query package later.

I don't know what impact that would have on type generation side of things, but on the runtime this would work, because the package resolver would make sure to include all of the necessary linked fragments in the correct order, as long as they properly specified in the deps arrays.

It should be possible to support even trickier cases like:

const A = graphql(`
  fragment A on Query {
    a
  }
`)

const B = graphql(`
  fragment B on Query {
    ...A
  }
`, [A])

const Query = graphql(`
  query {
    ...B
  }
`, [B])

As far as typescript errors - yeah, that's an issue. We'd need some way to validate that, maybe an eslint rule? Again, adding more complexity...

ucw commented 3 months ago

For my project, I wrote a rollup plugin based on babel plugin that replaces the source code of graphql queries with generated code. This way tree-shaking graphql.ts is completely eliminated. It is also important to enable enumsAsTypes: true in the code generator settings