diff --git a/.changeset/smart-ways-lay.md b/.changeset/smart-ways-lay.md new file mode 100644 index 00000000000..7712b4aa73e --- /dev/null +++ b/.changeset/smart-ways-lay.md @@ -0,0 +1,12 @@ +--- +"@apollo/client-codemod-migrate-3-to-4": major +--- + +Add a new `clientSetup` codemod step which applies the following steps from the migration guide to your Apollo Client setup code: + - Moves `uri`, `headers` and `credentials` to the `link` option and creates a new `HttpLink` instance + - Moves `name` and `version` into a `clientAwareness` option + - Adds a `localState` option with a new `LocalState` instance, moves `resolvers`, and removes `typeDefs` and `fragmentMatcher` options + - Changes the `connectToDevTools` option to `devtools.enabled` + - Renames `disableNetworkFetches` to `prioritizeCacheValues` + - If `dataMasking` is enabled, adds a template for global type augmentation to re-enable data masking types + - Adds the `incrementalHandler` option and adds a template for global type augmentation to accordingly type network responses in custom links diff --git a/scripts/codemods/ac3-to-ac4/README.md b/scripts/codemods/ac3-to-ac4/README.md index 02f45612eb2..2642f924482 100644 --- a/scripts/codemods/ac3-to-ac4/README.md +++ b/scripts/codemods/ac3-to-ac4/README.md @@ -21,6 +21,14 @@ In the order they will be applied per default if you don't manually specify a co - `links`: Moves `split`, `from`, `concat` and `empty` onto the `ApolloLink` namespace, changes funtion link invocations like `createHttpLink(...)` to their class equivalents like (`new HttpLink(...)`). Does not change `setContext((operation, prevContext) => {})` to `new ContextLink((prevContext, operation) => {})` - this requires manual intervention, as the order of callback arguments is flipped and this is not reliable codemoddable. - `removals`: Points all imports of values or types that have been removed in Apollo Client 4 to the `@apollo/client/v4-migration` entry point. That entry point contains context for each removal, oftentimes with migration instructions. +- `clientSetup`: Applies the following steps from the migration guide to your Apollo Client setup code + - Moves `uri`, `headers` and `credentials` to the `link` option and creates a new `HttpLink` instance + - Moves `name` and `version` into a `clientAwareness` option + - Adds a `localState` option with a new `LocalState` instance, moves `resolvers`, and removes `typeDefs` and `fragmentMatcher` options + - Changes the `connectToDevTools` option to `devtools.enabled` + - Renames `disableNetworkFetches` to `prioritizeCacheValues` + - If `dataMasking` is enabled, adds a template for global type augmentation to re-enable data masking types + - Adds the `incrementalHandler` option and adds a template for global type augmentation to accordingly type network responses in custom links ### Usage against TypeScript/TSX diff --git a/scripts/codemods/ac3-to-ac4/src/__tests__/apolloClientInitialization.test.ts b/scripts/codemods/ac3-to-ac4/src/__tests__/apolloClientInitialization.test.ts new file mode 100644 index 00000000000..f942b6d7ced --- /dev/null +++ b/scripts/codemods/ac3-to-ac4/src/__tests__/apolloClientInitialization.test.ts @@ -0,0 +1,894 @@ +import { applyTransform } from "jscodeshift/dist/testUtils"; +import { describe, expect, test } from "vitest"; + +import type { Steps } from "../apolloClientInitialization.js"; +import apolloClientInitializationTransform from "../apolloClientInitialization.js"; + +const transform = + (...enabledSteps: Steps[]) => + ([source]: TemplateStringsArray) => + applyTransform( + apolloClientInitializationTransform, + { + apolloClientInitialization: + enabledSteps.length > 0 ? enabledSteps : undefined, + }, + { source, path: "test.ts" }, + { parser: "ts" } + ); + +describe("all transforms", () => { + test("kitchen sink 1", () => { + expect(transform()` +import { ApolloClient, InMemoryCache } from "@apollo/client"; + +export const client = new ApolloClient({ + uri: "/graphql", + credentials: "include", + headers: { + "x-custom-header": "value", + }, + cache: new InMemoryCache(), + ssrForceFetchDelay: 50, + ssrMode: true, + connectToDevTools: true, + queryDeduplication: true, + defaultOptions: {}, + defaultContext: {}, + assumeImmutableResults: true, + resolvers: myResolvers, + typeDefs: mySchema, + fragmentMatcher: () => true, + name: "my-client", + version: "1.0.0", + documentTransform: myDocumentTransform, + dataMasking: true + }) + `).toMatchInlineSnapshot(` + "import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client"; + + import { Defer20220824Handler } from "@apollo/client/incremental"; + import { LocalState } from "@apollo/client/local-state"; + + export const client = new ApolloClient({ + cache: new InMemoryCache(), + ssrForceFetchDelay: 50, + ssrMode: true, + queryDeduplication: true, + defaultOptions: {}, + defaultContext: {}, + assumeImmutableResults: true, + documentTransform: myDocumentTransform, + + /* + Inserted by Apollo Client 3->4 migration codemod. + Keep this comment here if you intend to run the codemod again, + to avoid changes from being reapplied. + Delete this comment once you are done with the migration. + @apollo/client-codemod-migrate-3-to-4 applied + */ + dataMasking: true, + + link: new HttpLink({ + uri: "/graphql", + credentials: "include", + + headers: { + "x-custom-header": "value", + } + }), + + clientAwareness: { + name: "my-client", + version: "1.0.0" + }, + + localState: new LocalState({ + resolvers: myResolvers + }), + + devtools: { + enabled: true + }, + + /* + Inserted by Apollo Client 3->4 migration codemod. + If you are not using the \`@defer\` directive in your application, + you can safely remove this option. + */ + incrementalHandler: new Defer20220824Handler() + }) + + /* + Start: Inserted by Apollo Client 3->4 migration codemod. + Copy the contents of this block into a \`.d.ts\` file in your project + to enable data masking types. + */ + + + import "@apollo/client"; + import { GraphQLCodegenDataMasking } from "@apollo/client/masking"; + + declare module "@apollo/client" { + export interface TypeOverrides extends GraphQLCodegenDataMasking.TypeOverrides {} + } + + /* + End: Inserted by Apollo Client 3->4 migration codemod. + */ + + + /* + Start: Inserted by Apollo Client 3->4 migration codemod. + Copy the contents of this block into a \`.d.ts\` file in your project to enable correct response types in your custom links. + If you do not use the \`@defer\` directive in your application, you can safely remove this block. + */ + + + import "@apollo/client"; + import { Defer20220824Handler } from "@apollo/client/incremental"; + + declare module "@apollo/client" { + export interface TypeOverrides extends Defer20220824Handler.TypeOverrides {} + } + + /* + End: Inserted by Apollo Client 3->4 migration codemod. + */" + `); + }); +}); + +describe("http link intialization", () => { + test("all options", () => { + expect( + transform("explicitLinkConstruction")` +import { ApolloClient } from "@apollo/client"; + +new ApolloClient({ + uri: "https://example.com/graphql", + cache: new InMemoryCache(), + credentials: "include", + devtools: { enabled: true }, + headers: { + "x-custom-header": "value", + }, +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient, HttpLink } from "@apollo/client"; + + new ApolloClient({ + cache: new InMemoryCache(), + devtools: { enabled: true }, + + link: new HttpLink({ + uri: "https://example.com/graphql", + credentials: "include", + + headers: { + "x-custom-header": "value", + } + }) + })" + `); + }); + + test("only uri", () => { + expect( + transform("explicitLinkConstruction")` +import { ApolloClient, InMemoryCache } from "@apollo/client"; + +new ApolloClient({ + cache: new InMemoryCache(), + uri: "https://example.com/graphql", + devtools: { enabled: true }, +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client"; + + new ApolloClient({ + cache: new InMemoryCache(), + devtools: { enabled: true }, + link: new HttpLink({ + uri: "https://example.com/graphql" + }), + })" + `); + }); + + test("HttpLink import already there", () => { + expect( + transform("explicitLinkConstruction")` +import { ApolloClient } from "@apollo/client"; +import { HttpLink } from "@apollo/client/link/http"; + +new ApolloClient({ + uri: "https://example.com/graphql", + credentials: "include", + headers: { + "x-custom-header": "value", + }, +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + import { HttpLink } from "@apollo/client/link/http"; + + new ApolloClient({ + link: new HttpLink({ + uri: "https://example.com/graphql", + credentials: "include", + + headers: { + "x-custom-header": "value", + } + }) + })" + `); + }); + + test("HttpLink entry point already there", () => { + expect( + transform("explicitLinkConstruction")` +import { ApolloClient } from "@apollo/client"; +import { defaultPrinter } from "@apollo/client/link/http"; + +new ApolloClient({ + uri: "https://example.com/graphql", + credentials: "include", + headers: { + "x-custom-header": "value", + }, +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + import { defaultPrinter, HttpLink } from "@apollo/client/link/http"; + + new ApolloClient({ + link: new HttpLink({ + uri: "https://example.com/graphql", + credentials: "include", + + headers: { + "x-custom-header": "value", + } + }) + })" + `); + }); + + test("link already present inline", () => { + expect( + transform("explicitLinkConstruction")` +import { ApolloClient } from "@apollo/client"; +import { BatchHttpLink } from "@apollo/client/link/batch-http"; + +new ApolloClient({ + link: new BatchHttpLink({ + uri: "http://localhost:4000/graphql", + batchMax: 5, + batchInterval: 20 + }) +}) +` + ).toMatchInlineSnapshot(`""`); + }); + + test("link already present shorthand", () => { + expect( + transform("explicitLinkConstruction")` +import { ApolloClient } from "@apollo/client"; +import { BatchHttpLink } from "@apollo/client/link/batch-http"; + +const link = new BatchHttpLink({ + uri: "http://localhost:4000/graphql", + batchMax: 5, + batchInterval: 20 +}); + +new ApolloClient({ + link +}) +` + ).toMatchInlineSnapshot(`""`); + }); +}); + +describe("client awareness", () => { + test("name and version", () => { + expect( + transform("clientAwareness")` +import { ApolloClient } from "@apollo/client"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + name: "my-client", + version: "1.0.0", +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + + clientAwareness: { + name: "my-client", + version: "1.0.0" + } + })" + `); + }); + test("name only", () => { + expect( + transform("clientAwareness")` +import { ApolloClient } from "@apollo/client"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + name: "my-client", +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + clientAwareness: { + name: "my-client" + }, + })" + `); + }); + test("version only", () => { + expect( + transform("clientAwareness")` +import { ApolloClient } from "@apollo/client"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + version: "1.0.0", +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + clientAwareness: { + version: "1.0.0" + }, + })" + `); + }); +}); + +describe("local state", () => { + test("with resolvers inline", () => { + expect( + transform("localState")` +import { ApolloClient } from "@apollo/client"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + resolvers: { + foo: () => "bar", + } +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + import { LocalState } from "@apollo/client/local-state"; + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + localState: new LocalState({ + resolvers: { + foo: () => "bar", + } + }) + })" + `); + }); + test("with resolvers variable", () => { + expect( + transform("localState")` +import { ApolloClient } from "@apollo/client"; + +const myResolvers = {} + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + resolvers: myResolvers +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + import { LocalState } from "@apollo/client/local-state"; + + const myResolvers = {} + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + localState: new LocalState({ + resolvers: myResolvers + }) + })" + `); + }); + test("with resolvers variable (shorthand)", () => { + expect( + transform("localState")` +import { ApolloClient } from "@apollo/client"; + +const resolvers = {} + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + resolvers +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + import { LocalState } from "@apollo/client/local-state"; + + const resolvers = {} + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + localState: new LocalState({ + resolvers + }) + })" + `); + }); + test("without resolvers", () => { + expect( + transform("localState")` +import { ApolloClient } from "@apollo/client"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + import { LocalState } from "@apollo/client/local-state"; + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + + /* + Inserted by Apollo Client 3->4 migration codemod. + If you are not using the \`@client\` directive in your application, + you can safely remove this option. + */ + localState: new LocalState({}) + })" + `); + }); +}); + +describe("devtools option", () => { + test("`true`", () => { + expect( + transform("devtoolsOption")` +import { ApolloClient } from "@apollo/client"; + + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + connectToDevTools: true +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + devtools: { + enabled: true + } + })" + `); + }); + test("`false`", () => { + expect( + transform("devtoolsOption")` +import { ApolloClient } from "@apollo/client"; + + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + connectToDevTools: false +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + devtools: { + enabled: false + } + })" + `); + }); + test("variable", () => { + expect( + transform("devtoolsOption")` +import { ApolloClient } from "@apollo/client"; + +const shouldConnectToDevTools = process.env.NODE_ENV === 'development'; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + // see if this comment moves around too + connectToDevTools: shouldConnectToDevTools +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + const shouldConnectToDevTools = process.env.NODE_ENV === 'development'; + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + devtools: { + // see if this comment moves around too + enabled: shouldConnectToDevTools + } + })" + `); + }); + test("process.env.NODE_ENV === 'development'", () => { + expect( + transform("devtoolsOption")` +import { ApolloClient } from "@apollo/client"; + + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + connectToDevTools: process.env.NODE_ENV === 'development' +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + devtools: { + enabled: process.env.NODE_ENV === 'development' + } + })" + `); + }); + test("shorthand", () => { + expect( + transform("devtoolsOption")` +import { ApolloClient } from "@apollo/client"; + +const connectToDevTools = process.env.NODE_ENV === 'development'; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + connectToDevTools +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + const connectToDevTools = process.env.NODE_ENV === 'development'; + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + devtools: { + enabled: connectToDevTools + } + })" + `); + }); +}); + +describe("disableNetworkFetches option", () => { + test("`true`", () => { + expect( + transform("prioritizeCacheValues")` +import { ApolloClient } from "@apollo/client"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + disableNetworkFetches: true +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + prioritizeCacheValues: true + })" + `); + }); + test("`false`", () => { + expect( + transform("prioritizeCacheValues")` +import { ApolloClient } from "@apollo/client"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + disableNetworkFetches: false +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + prioritizeCacheValues: false + })" + `); + }); + test("variable", () => { + expect( + transform("prioritizeCacheValues")` +import { ApolloClient } from "@apollo/client"; + +const onServer = typeof window === "undefined"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + disableNetworkFetches: onServer +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + const onServer = typeof window === "undefined"; + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + prioritizeCacheValues: onServer + })" + `); + }); + test("shorthand", () => { + expect( + transform("prioritizeCacheValues")` +import { ApolloClient } from "@apollo/client"; + +const disableNetworkFetches = typeof window === "undefined"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + disableNetworkFetches +}) +` + ).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + const disableNetworkFetches = typeof window === "undefined"; + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + prioritizeCacheValues: disableNetworkFetches + })" + `); + }); +}); +describe("dataMasking types", () => { + test("applied if `dataMasking` is set", () => { + expect(transform("dataMasking")` +import { ApolloClient } from "@apollo/client"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + dataMasking: true, +}) + `).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + /* + Inserted by Apollo Client 3->4 migration codemod. + Keep this comment here if you intend to run the codemod again, + to avoid changes from being reapplied. + Delete this comment once you are done with the migration. + @apollo/client-codemod-migrate-3-to-4 applied + */ + dataMasking: true, + }) + + /* + Start: Inserted by Apollo Client 3->4 migration codemod. + Copy the contents of this block into a \`.d.ts\` file in your project + to enable data masking types. + */ + + + import "@apollo/client"; + import { GraphQLCodegenDataMasking } from "@apollo/client/masking"; + + declare module "@apollo/client" { + export interface TypeOverrides extends GraphQLCodegenDataMasking.TypeOverrides {} + } + + /* + End: Inserted by Apollo Client 3->4 migration codemod. + */" + `); + }); + + test("not applied if `dataMasking` is not set`", () => { + expect(transform("dataMasking")` +import { ApolloClient } from "@apollo/client"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, +}) + `).toMatchInlineSnapshot(`""`); + }); + + test("not applied if `dataMasking` is set to `false`", () => { + expect(transform("dataMasking")` +import { ApolloClient } from "@apollo/client"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + dataMasking: false, +}) + `).toMatchInlineSnapshot(`""`); + }); + + test("does not reapply on a second run (full comment in place)", () => { + const once = transform("dataMasking")` +import { ApolloClient } from "@apollo/client"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + dataMasking: true, +}) + `; + const twice = transform("dataMasking")([once] as any); + expect(twice).toEqual("" /* empty string -> no transformation */); + }); + + test("does not reapply on a second run (full comment in place, added code moved out)", () => { + expect(transform("dataMasking")` +import { ApolloClient } from "@apollo/client"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + /* + Inserted by Apollo Client 3->4 migration codemod. + Keep this comment here if you intend to run the codemod again, + to avoid changes from being reapplied. + Delete this comment once you are done with the migration. + @apollo/client-codemod-migrate-3-to-4 applied + */ + dataMasking: true, +}) + `).toMatchInlineSnapshot(`""`); + }); + + test("does not reapply on a second run (only marker left in place, added code moved out)", () => { + expect(transform("dataMasking")` +import { ApolloClient } from "@apollo/client"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + /* @apollo/client-codemod-migrate-3-to-4 applied */ + dataMasking: true, +}) + `).toMatchInlineSnapshot(`""`); + }); +}); + +describe("incrementalHandler", () => { + test("added to ApolloClient constructor options", () => { + expect(transform("incrementalHandler")` +import { ApolloClient } from "@apollo/client"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, +}) + `).toMatchInlineSnapshot(` + "import { ApolloClient } from "@apollo/client"; + + import { Defer20220824Handler } from "@apollo/client/incremental"; + + new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + + /* + Inserted by Apollo Client 3->4 migration codemod. + If you are not using the \`@defer\` directive in your application, + you can safely remove this option. + */ + incrementalHandler: new Defer20220824Handler() + }) + + /* + Start: Inserted by Apollo Client 3->4 migration codemod. + Copy the contents of this block into a \`.d.ts\` file in your project to enable correct response types in your custom links. + If you do not use the \`@defer\` directive in your application, you can safely remove this block. + */ + + + import "@apollo/client"; + import { Defer20220824Handler } from "@apollo/client/incremental"; + + declare module "@apollo/client" { + export interface TypeOverrides extends Defer20220824Handler.TypeOverrides {} + } + + /* + End: Inserted by Apollo Client 3->4 migration codemod. + */" + `); + }); + + test("not added to ApolloClient constructor options if an `incrementalHandler` option is already present", () => { + expect(transform("incrementalHandler")` +import { ApolloClient } from "@apollo/client"; + +new ApolloClient({ + cache: new InMemoryCache(), + link: someLink, + incrementalHandler: undefined +}) + `).toMatchInlineSnapshot(`""`); + }); +}); diff --git a/scripts/codemods/ac3-to-ac4/src/__tests__/handleIdentifierRename.test.ts b/scripts/codemods/ac3-to-ac4/src/__tests__/handleIdentifierRename.test.ts index 055ae33117c..83c71403867 100644 --- a/scripts/codemods/ac3-to-ac4/src/__tests__/handleIdentifierRename.test.ts +++ b/scripts/codemods/ac3-to-ac4/src/__tests__/handleIdentifierRename.test.ts @@ -9,7 +9,7 @@ declare module "jscodeshift/dist/testUtils" { export function applyTransform( module: import("jscodeshift").Transform, options: Options, - input: { source: string }, + input: { source: string; path?: string }, testOptions?: Record ): string; } diff --git a/scripts/codemods/ac3-to-ac4/src/apolloClientInitialization.ts b/scripts/codemods/ac3-to-ac4/src/apolloClientInitialization.ts new file mode 100644 index 00000000000..ec70e1ab98a --- /dev/null +++ b/scripts/codemods/ac3-to-ac4/src/apolloClientInitialization.ts @@ -0,0 +1,490 @@ +import assert from "node:assert"; + +import type { namedTypes } from "ast-types"; +import type * as j from "jscodeshift"; + +import type { IdentifierRename } from "./renames.js"; +import type { UtilContext } from "./types.js"; +import { findImportSpecifiersFor } from "./util/findImportSpecifiersFor.js"; +import { findOrInsertImport } from "./util/findOrInsertImport.js"; +import { findReferences } from "./util/findReferences.js"; +import { getProperty } from "./util/getProperty.js"; + +const steps = { + explicitLinkConstruction, + clientAwareness, + localState, + devtoolsOption, + prioritizeCacheValues, + dataMasking, + incrementalHandler, +} satisfies Record void>; + +export type Steps = keyof typeof steps; + +const apolloClientInitializationTransform: j.Transform = function transform( + file, + api, + options = {} +) { + const j = api.jscodeshift; + const source = j(file.source); + const context = { j, source }; + + let modified = false; + function onModified() { + modified = true; + } + const enabledStepNames = + Array.isArray(options.apolloClientInitialization) ? + options.apolloClientInitialization + : Object.keys(steps); + const enabledSteps = Object.fromEntries( + Object.entries(steps).filter(([name]) => enabledStepNames.includes(name)) + ); + + for (const constructorCall of apolloClientConstructions({ context })) { + const options = { + context, + onModified, + constructorCall, + prop: (name: string) => + getProperty({ + context, + objectPath: constructorCall.optionsPath, + name, + }), + file, + }; + for (const step of Object.values(enabledSteps)) { + step(options); + } + } + + return modified ? source.toSource() : undefined; +}; +export default apolloClientInitializationTransform; + +interface StepOptions { + context: UtilContext; + constructorCall: ConstructorCall; + onModified: () => void; + prop: (name: string) => j.ASTPath | null; + file: j.FileInfo; +} + +function explicitLinkConstruction({ + context, + context: { j }, + constructorCall: { optionsPath }, + onModified, + prop, +}: StepOptions) { + if (prop("link")) { + return; + } + onModified(); + + const uriPath = prop("uri"); + const uri = uriPath?.node; + uriPath?.replace(); + + const credentialsPath = prop("credentials"); + const credentials = credentialsPath?.node; + credentialsPath?.replace(); + + const headersPath = prop("headers"); + const headers = headersPath?.node; + headersPath?.replace(); + + const linkSpec = findOrInsertImport({ + context, + description: { + module: "@apollo/client/link/http", + identifier: "HttpLink", + alternativeModules: ["@apollo/client"], + }, + compatibleWith: "value", + }); + + optionsPath.node.properties.push( + j.objectProperty.from({ + key: j.identifier("link"), + value: j.newExpression.from({ + callee: linkSpec.local || linkSpec.imported, + arguments: [ + j.objectExpression.from({ + properties: [uri, credentials, headers].filter((prop) => !!prop), + }), + ], + }), + }) + ); +} + +function clientAwareness({ + context: { j }, + constructorCall: { optionsPath }, + onModified, + prop, +}: StepOptions) { + const namePath = prop("name"); + const name = namePath?.node; + namePath?.replace(); + + const versionPath = prop("version"); + const version = versionPath?.node; + versionPath?.replace(); + + if (!namePath && !versionPath) { + return; + } + onModified(); + + optionsPath.node.properties.push( + j.objectProperty.from({ + key: j.identifier("clientAwareness"), + value: j.objectExpression.from({ + properties: [name, version].filter((prop) => !!prop), + }), + }) + ); +} + +function localState({ + context, + context: { j }, + constructorCall: { specPath, optionsPath }, + onModified, + prop, +}: StepOptions) { + const resolversPath = prop("resolvers"); + const resolvers = resolversPath?.node; + resolversPath?.replace(); + + const typeDefsPath = prop("typeDefs"); + typeDefsPath?.replace(); + + const fragmentMatcherPath = prop("fragmentMatcher"); + fragmentMatcherPath?.replace(); + + if (resolversPath || typeDefsPath || fragmentMatcherPath) { + onModified(); + } + if (prop("localState")) { + return; + } + onModified(); + const localStateSpec = findOrInsertImport({ + context, + description: { + module: "@apollo/client/local-state", + identifier: "LocalState", + }, + compatibleWith: "value", + after: j(specPath).closest(j.ImportDeclaration), + }); + + optionsPath.node.properties.push( + j.objectProperty.from({ + key: j.identifier("localState"), + value: j.newExpression.from({ + callee: localStateSpec.local || localStateSpec.imported, + arguments: [ + j.objectExpression.from({ + properties: [resolvers].filter((prop) => !!prop), + }), + ], + }), + comments: + resolvers ? + [] + : [ + j.commentBlock.from({ + leading: true, + value: ` +Inserted by Apollo Client 3->4 migration codemod. +If you are not using the \`@client\` directive in your application, +you can safely remove this option. +`, + }), + ], + }) + ); +} + +function devtoolsOption({ + context: { j }, + constructorCall: { optionsPath }, + onModified, + prop, +}: StepOptions) { + const devtoolsPath = prop("connectToDevTools"); + const node = devtoolsPath?.node; + devtoolsPath?.replace(); + if (!devtoolsPath) { + return; + } + onModified(); + + assert(node); + + if (node.shorthand) { + node.value = node.key; + node.shorthand = false; + } + node.key = j.identifier("enabled"); + optionsPath.node.properties.push( + j.objectProperty.from({ + key: j.identifier("devtools"), + value: j.objectExpression.from({ + properties: [node], + }), + }) + ); +} + +function prioritizeCacheValues({ + context: { j }, + onModified, + prop, +}: StepOptions) { + const devtoolsPath = prop("disableNetworkFetches"); + if (!devtoolsPath) { + return; + } + onModified(); + + const node = devtoolsPath.node; + if (node.shorthand) { + node.value = node.key; + node.shorthand = false; + } + node.key = j.identifier("prioritizeCacheValues"); +} + +interface ConstructorCall { + specPath: j.ASTPath; + newExprPath: j.ASTPath; + optionsPath: j.ASTPath; +} + +function dataMasking({ + context, + context: { j, source }, + onModified, + prop, + file, +}: StepOptions) { + if (!file.path.endsWith(".ts") && !file.path.endsWith(".tsx")) { + // avoid inserting data masking types in non-TypeScript files + return; + } + + const dataMaskingPath = prop("dataMasking"); + const dataMasking = dataMaskingPath?.node; + if ( + !dataMasking || + (j.BooleanLiteral.check(dataMasking.value) && + dataMasking.value.value === false) || + dataMasking.comments?.some((comment) => + CODEMOD_MARKER_REGEX("applied").test(comment.value) + ) + ) { + return; + } + + onModified(); + dataMasking.comments ??= []; + dataMasking.comments.push( + j.commentBlock.from({ + leading: true, + value: ` +Inserted by Apollo Client 3->4 migration codemod. +Keep this comment here if you intend to run the codemod again, +to avoid changes from being reapplied. +Delete this comment once you are done with the migration. +${CODEMOD_MARKER} applied +`, + }) + ); + + insertTypeOverrideBlock({ + context, + leadingComment: `Copy the contents of this block into a \`.d.ts\` file in your project +to enable data masking types.`, + overridingType: { + module: "@apollo/client/masking", + namespace: "GraphQLCodegenDataMasking", + identifier: "TypeOverrides", + }, + }); +} + +function incrementalHandler({ + context, + context: { j, source }, + constructorCall: { specPath, optionsPath }, + onModified, + prop, + file, +}: StepOptions) { + if (prop("incrementalHandler")) { + return; + } + onModified(); + + const deferHandlerSpec = findOrInsertImport({ + context, + description: { + module: "@apollo/client/incremental", + identifier: "Defer20220824Handler", + }, + compatibleWith: "value", + after: j(specPath).closest(j.ImportDeclaration), + }); + + optionsPath.node.properties.push( + j.objectProperty.from({ + key: j.identifier("incrementalHandler"), + value: j.newExpression.from({ + callee: deferHandlerSpec.local || deferHandlerSpec.imported, + arguments: [], + }), + comments: [ + j.commentBlock.from({ + leading: true, + value: ` +Inserted by Apollo Client 3->4 migration codemod. +If you are not using the \`@defer\` directive in your application, +you can safely remove this option. +`, + }), + ], + }) + ); + + if (!file.path.endsWith(".ts") && !file.path.endsWith(".tsx")) { + // avoid inserting defer types in non-TypeScript files + return; + } + + insertTypeOverrideBlock({ + context, + leadingComment: `Copy the contents of this block into a \`.d.ts\` file in your project to enable correct response types in your custom links. +If you do not use the \`@defer\` directive in your application, you can safely remove this block.`, + overridingType: { + module: "@apollo/client/incremental", + namespace: "Defer20220824Handler", + identifier: "TypeOverrides", + }, + }); +} + +function insertTypeOverrideBlock({ + context: { source, j }, + leadingComment, + overridingType: { identifier, module, namespace }, +}: { + context: UtilContext; + leadingComment: string; + overridingType: Required< + Pick + >; +}) { + const program = source.find(j.Program).nodes()[0]!; + program.body.push( + j.emptyStatement.from({ + comments: [ + j.commentBlock.from({ + leading: true, + value: ` +Start: Inserted by Apollo Client 3->4 migration codemod. +${leadingComment} +`, + }), + ], + }), + j.importDeclaration.from({ source: j.literal("@apollo/client") }), + j.importDeclaration.from({ + specifiers: [ + j.importSpecifier.from({ + imported: j.identifier(namespace), + }), + ], + source: j.literal(module), + }), + j.tsModuleDeclaration.from({ + id: j.stringLiteral("@apollo/client"), + declare: true, + body: j.tsModuleBlock.from({ + body: [ + j.exportNamedDeclaration.from({ + declaration: j.tsInterfaceDeclaration.from({ + id: j.identifier("TypeOverrides"), + extends: [ + j.tsExpressionWithTypeArguments.from({ + expression: j.tsQualifiedName.from({ + left: j.identifier(namespace), + right: j.identifier(identifier), + }), + }), + ], + body: j.tsInterfaceBody.from({ body: [] }), + }), + }), + ], + }), + }), + j.emptyStatement.from({ + comments: [ + j.commentBlock.from({ + leading: true, + value: ` +End: Inserted by Apollo Client 3->4 migration codemod. +`, + }), + ], + }) + ); +} + +function* apolloClientConstructions({ + context, + context: { j }, +}: { + context: UtilContext; +}): Generator { + for (const specPath of findImportSpecifiersFor({ + description: { + module: "@apollo/client", + identifier: "ApolloClient", + alternativeModules: ["@apollo/client/core"], + }, + compatibleWith: "value", + context, + }).paths()) { + for (const newExprPath of findReferences({ + context, + identifier: (specPath.node.local || specPath.node.imported).name + "", + scope: specPath.scope, + }) + .map((usage) => + j.NewExpression.check(usage.parentPath.node) ? usage.parentPath : null + ) + .paths()) { + const optionsPath = newExprPath.get("arguments", 0); + if (optionsPath && j.ObjectExpression.check(optionsPath.node)) { + yield { + specPath, + newExprPath, + optionsPath: optionsPath as j.ASTPath, + }; + } + } + } +} + +const CODEMOD_MARKER = `@apollo/client-codemod-migrate-3-to-4`; +const CODEMOD_MARKER_REGEX = (keyword: string) => + new RegExp(`^\\s*(?:[*]?\\s*)${CODEMOD_MARKER} ${keyword}\\s*$`, "m"); diff --git a/scripts/codemods/ac3-to-ac4/src/index.ts b/scripts/codemods/ac3-to-ac4/src/index.ts index b85438412fe..422256f4a94 100644 --- a/scripts/codemods/ac3-to-ac4/src/index.ts +++ b/scripts/codemods/ac3-to-ac4/src/index.ts @@ -1,5 +1,6 @@ import type { API, FileInfo, Options, Transform } from "jscodeshift"; +import clientSetup from "./apolloClientInitialization.js"; import imports from "./imports.js"; import legacyEntrypoints from "./legacyEntrypoints.js"; import links from "./links.js"; @@ -11,6 +12,7 @@ export const codemods = { imports, links, removals, + clientSetup, } satisfies Record; export default async function transform( diff --git a/scripts/codemods/ac3-to-ac4/src/util/findImportSpecifiersFor.ts b/scripts/codemods/ac3-to-ac4/src/util/findImportSpecifiersFor.ts index cdee5aa67db..80358f43eb7 100644 --- a/scripts/codemods/ac3-to-ac4/src/util/findImportSpecifiersFor.ts +++ b/scripts/codemods/ac3-to-ac4/src/util/findImportSpecifiersFor.ts @@ -1,3 +1,6 @@ +import type { namedTypes } from "ast-types"; +import type * as j from "jscodeshift"; + import type { IdentifierRename } from "../renames.js"; import type { ImportKind, UtilContext } from "../types.js"; diff --git a/scripts/codemods/ac3-to-ac4/src/util/findOrInsertImport.ts b/scripts/codemods/ac3-to-ac4/src/util/findOrInsertImport.ts new file mode 100644 index 00000000000..58b9318e7a8 --- /dev/null +++ b/scripts/codemods/ac3-to-ac4/src/util/findOrInsertImport.ts @@ -0,0 +1,55 @@ +import type * as j from "jscodeshift/src/core.js"; + +import type { IdentifierRename } from "../renames.js"; +import type { ImportKind, UtilContext } from "../types.js"; + +import { findImportDeclarationFor } from "./findImportDeclarationFor.js"; +import { findImportSpecifiersFor } from "./findImportSpecifiersFor.js"; + +export function findOrInsertImport({ + context, + context: { j, source }, + description, + compatibleWith, + after, +}: { + context: UtilContext; + description: IdentifierRename["from"]; + compatibleWith: ImportKind; + after?: j.Collection; +}) { + const found = findImportSpecifiersFor({ + description, + context, + compatibleWith, + }).nodes()[0]; + if (found) { + return found; + } + let addInto = findImportDeclarationFor({ + description, + context, + compatibleWith, + }).nodes()[0]; + if (!addInto) { + addInto = j.importDeclaration.from({ + specifiers: [], + source: j.literal(description.module), + importKind: compatibleWith, + }); + if (!after) { + after = source.find(j.ImportDeclaration); + } + if (!after || after.size() === 0) { + const program = source.find(j.Program).nodes()[0]!; + program.body.unshift(addInto); + } else { + after.at(-1).insertAfter(addInto); + } + } + const spec = j.importSpecifier.from({ + imported: j.identifier(description.identifier), + }); + (addInto.specifiers ??= []).push(spec); + return spec; +} diff --git a/scripts/codemods/ac3-to-ac4/src/util/getProperty.ts b/scripts/codemods/ac3-to-ac4/src/util/getProperty.ts new file mode 100644 index 00000000000..63b534f4b10 --- /dev/null +++ b/scripts/codemods/ac3-to-ac4/src/util/getProperty.ts @@ -0,0 +1,23 @@ +import type { namedTypes } from "ast-types"; +import type * as j from "jscodeshift/src/core.js"; + +import type { UtilContext } from "../types.js"; + +export function getProperty({ + context: { j }, + objectPath, + name, +}: { + objectPath: j.ASTPath; + context: UtilContext; + name: string; +}): j.ASTPath | null { + return ( + (objectPath.get("properties") as j.ASTPath).filter( + (path: j.ASTPath) => + j.ObjectProperty.check(path.node) && + j.Identifier.check(path.node.key) && + path.node.key.name === name + )[0] || null + ); +}