diff --git a/hooks.js b/hooks.js new file mode 100644 index 00000000..6354671b --- /dev/null +++ b/hooks.js @@ -0,0 +1 @@ +module.exports = require("./lib/hooks"); diff --git a/package.json b/package.json index d4ae0bf3..622f134c 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ ], "main": "lib/index.js", "files": [ - "lib" + "lib", + "hooks.js" ], "scripts": { "build": "rm -rf lib && tsc --project tsconfig.build.json", @@ -83,8 +84,8 @@ "@types/jest": "^26.0.20", "@types/node": "14.14.28", "@types/relay-compiler": "^8.0.0", - "@types/relay-runtime": "^10.0.1", - "babel-plugin-relay": "^10.0.0", + "@types/relay-runtime": "^10.1.10", + "babel-plugin-relay": "^11.0.0", "chokidar-cli": "^2.0.0", "concurrently": "^5.0.0", "glob": "^7.1.6", @@ -94,9 +95,9 @@ "jest-cli": "^26.6.3", "lint-staged": "^10.5.3", "prettier": "^2.2.1", - "relay-compiler": "^10.0.1", - "relay-runtime": "^10.0.1", - "relay-test-utils-internal": "^10.0.1", + "relay-compiler": "^11.0.0", + "relay-runtime": "^11.0.0", + "relay-test-utils-internal": "^11.0.0", "ts-jest": "^26.4.4", "ts-node": "^9.1.1", "tslint": "^6.1.3", @@ -104,10 +105,10 @@ "typescript": "4.1.5" }, "peerDependencies": { - "@types/react-relay": ">=7.0.0", - "@types/relay-runtime": ">=6.0.7", - "relay-compiler": ">=9.0.0", - "relay-runtime": ">=9.0.0", + "@types/react-relay": "^11.0.0", + "@types/relay-runtime": ">=10.1.10", + "relay-compiler": ">=11.0.0", + "relay-runtime": ">=11.0.0", "typescript": ">=3.6.4" }, "publishConfig": { diff --git a/src/formatGeneratedModule.ts b/src/formatGeneratedModule.ts index 0af4c098..442c6de4 100644 --- a/src/formatGeneratedModule.ts +++ b/src/formatGeneratedModule.ts @@ -2,23 +2,36 @@ import { FormatModule } from "relay-compiler"; import * as ts from "typescript"; import addAnyTypeCast from "./addAnyTypeCast"; +interface FormatterOptions { + makeImports: (moduleDetails: Parameters[0]) => string; + append?: string; +} + +const defaultFormatOptions: FormatterOptions = { + makeImports({ documentType }) { + return documentType + ? `import { ${documentType} } from "relay-runtime";` + : ""; + }, +}; + export const formatterFactory = ( - compilerOptions: ts.CompilerOptions = {} -): FormatModule => ({ - moduleName, - documentType, - docText, - concreteText, - typeText, - hash, - sourceHash -}) => { - const documentTypeImport = documentType - ? `import { ${documentType} } from "relay-runtime";` - : ""; + compilerOptions: ts.CompilerOptions = {}, + { makeImports, append }: FormatterOptions = defaultFormatOptions +): FormatModule => (details) => { + const { + documentType, + docText, + concreteText, + typeText, + hash, + sourceHash, + } = details; + const docTextComment = docText ? "\n/*\n" + docText.trim() + "\n*/\n" : ""; - let nodeStatement = `const node: ${documentType || - "never"} = ${concreteText};`; + let nodeStatement = `const node: ${ + documentType || "never" + } = ${concreteText};`; if (compilerOptions.noImplicitAny) { nodeStatement = addAnyTypeCast(nodeStatement).trim(); } @@ -26,12 +39,12 @@ export const formatterFactory = ( /* eslint-disable */ // @ts-nocheck ${hash ? `/* ${hash} */\n` : ""} -${documentTypeImport} +${makeImports(details)} ${typeText || ""} ${docTextComment} ${nodeStatement} (node as any).hash = '${sourceHash}'; export default node; -`; +${append ? `\n${append}\n` : ""}`; }; diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 00000000..5f229565 --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,289 @@ +import { FormatModule, LocalArgumentDefinition } from "relay-compiler"; +import { formatterFactory } from "./formatGeneratedModule"; +import relayCompilerLanguageTypescript from "./index"; +import { loadCompilerOptions } from "./loadCompilerOptions"; + +export default function relayHooksTypescriptCompiler() { + const compilerOptions = loadCompilerOptions(); + + const formatModule: FormatModule = (opts) => { + const { + documentType, + definition, + definition: { name, metadata }, + } = opts; + + const allHooks: string[] = []; + const typeImports: string[] = []; + const reactImports: string[] = []; + const reactRelayImports: string[] = []; + const relayRuntimeImports: string[] = []; + + if (documentType) { + relayRuntimeImports.push(documentType); + } + + const meta = (metadata ?? {}) as CompilerMeta; + + if (!meta.derivedFrom) { + if (definition.kind === "Fragment") { + if (meta.refetch) { + typeImports.push( + `import { ${meta.refetch.operation} } from "./${meta.refetch.operation}.graphql"` + ); + if (meta.connection) { + relayRuntimeImports.push("OperationType"); + reactRelayImports.push( + "LoadMoreFn", + "RefetchFnDynamic", + "usePaginationFragment" + ); + allHooks.push( + makePaginationFragmentBlock(name, meta.refetch.operation) + ); + } else { + reactRelayImports.push( + "useRefetchableFragment", + "RefetchFnDynamic" + ); + allHooks.push( + makeRefetchableFragmentBlock(name, meta.refetch.operation) + ); + } + } else { + reactRelayImports.push("useFragment"); + allHooks.push(makeFragmentBlock(name)); + } + } else if (definition.kind === "Request") { + if (definition.root.operation === "query") { + // Common across the query fns + relayRuntimeImports.push( + "VariablesOf", + "FetchPolicy", + "CacheConfig", + "RenderPolicy" + ); + relayRuntimeImports.push("IEnvironment"); + reactRelayImports.push( + "loadQuery", + "LoadQueryOptions", + "EnvironmentProviderOptions" + ); + allHooks.push( + makeLoadBlock(name, definition.root.argumentDefinitions) + ); + reactRelayImports.push("useLazyLoadQuery"); + allHooks.push( + makeLazyLoadBlock(name, definition.root.argumentDefinitions) + ); + + reactRelayImports.push("useQueryLoader", "PreloadedQuery"); + allHooks.push(makeQueryLoaderBlock(name)); + + reactRelayImports.push("usePreloadedQuery"); + allHooks.push(makePreloadedQueryBlock(name)); + } else if (definition.root.operation === "mutation") { + relayRuntimeImports.push( + "MutationConfig", + "IEnvironment", + "Disposable" + ); + reactRelayImports.push("useMutation"); + allHooks.push(makeMutationBlock(name)); + } else if (definition.root.operation === "subscription") { + reactImports.push("useMemo"); + relayRuntimeImports.push( + "GraphQLSubscriptionConfig", + "requestSubscription" + ); + reactRelayImports.push("useSubscription"); + allHooks.push( + makeSubscriptionBlock(name, definition.root.argumentDefinitions) + ); + } + } + } + + const allImports: string[] = []; + + if (relayRuntimeImports.length) { + allImports.push(makeImport(relayRuntimeImports, "relay-runtime")); + } + if (reactRelayImports.length) { + allImports.push(makeImport(reactRelayImports, "react-relay")); + } + if (reactImports.length) { + allImports.push(makeImport(reactImports, "react")); + } + if (typeImports) { + allImports.push(typeImports.join("\n")); + } + + return formatterFactory(compilerOptions, { + makeImports() { + return allImports.join("\n"); + }, + append: allHooks.join("\n"), + })(opts); + }; + + return { + ...relayCompilerLanguageTypescript(), + formatModule, + }; +} + +function capitalize(str: string) { + return `${str[0].toUpperCase()}${str.slice(1)}`; +} + +interface CompilerMeta { + derivedFrom?: string; + connection?: unknown[]; + refetch?: { + connection: unknown; + operation: string; + fragmentPathInResult: unknown[]; + identifierField: unknown; + }; +} + +function makeImport(idents: string[], from: string) { + return `import { ${Array.from(new Set(idents)) + .sort() + .join(", ")} } from "${from}";`; +} + +function makeFragmentBlock(name: string) { + const n = capitalize(name); + + // NOTE: These declares ensure that the type of the returned data is: + // - non-nullable if the provided ref type is non-nullable + // - nullable if the provided ref type is nullable + // - array of non-nullable if the provided ref type is an array of + // non-nullable refs + // - array of nullable if the provided ref type is an array of nullable refs + + return `export function use${n}Fragment(fragmentRef: TKey): Required[" $data"] +export function use${n}Fragment(fragmentRef: TKey | null): Required[" $data"] | null +export function use${n}Fragment(fragmentRef: ReadonlyArray): ReadonlyArray[" $data"]> +export function use${n}Fragment(fragmentRef: ReadonlyArray): ReadonlyArray[" $data"] | null> +export function use${n}Fragment(fragmentRef: ReadonlyArray | null): ReadonlyArray[" $data"]> | null +export function use${n}Fragment(fragmentRef: ReadonlyArray | null): ReadonlyArray[" $data"] | null> | null +export function use${n}Fragment(fragmentRef: any) { + return useFragment(node, fragmentRef) +}`; +} + +function makeRefetchableFragmentBlock(name: string, operation: string) { + const n = capitalize(name); + return `export function useRefetchable${n}Fragment(fragmentRef: TKey): [Required[" $data"], RefetchFnDynamic<${operation}, ${n}$key>] +export function useRefetchable${n}Fragment(fragmentRef: TKey | null): [Required[" $data"] | null, RefetchFnDynamic<${operation}, ${n}$key | null>] +export function useRefetchable${n}Fragment(fragmentRef: any) { + return useRefetchableFragment(node, fragmentRef) +}`; +} + +function makePaginationFragmentBlock(name: string, operation: string) { + const n = capitalize(name); + + // Note: It'd be nice if react-relay exported this type for us + return `interface usePaginationFragmentHookType { + data: TFragmentData; + loadNext: LoadMoreFn; + loadPrevious: LoadMoreFn; + hasNext: boolean; + hasPrevious: boolean; + isLoadingNext: boolean; + isLoadingPrevious: boolean; + refetch: RefetchFnDynamic; +} + +export function usePaginated${n}(fragmentRef: K): usePaginationFragmentHookType<${operation}, ${name}$key, Required[" $data"]> +export function usePaginated${n}(fragmentRef: K | null): usePaginationFragmentHookType<${operation}, ${name}$key | null, Required[" $data"] | null>; +export function usePaginated${n}(fragmentRef: any) { + return usePaginationFragment<${operation}, ${name}$key>(node, fragmentRef) +}`; +} + +function makePreloadedQueryBlock(name: string) { + const n = capitalize(name); + return `type PreloadedQueryOptions = Parameters[2]; + +export function usePreloaded${n}(preloadedQuery: PreloadedQuery<${name}>, options?: PreloadedQueryOptions) { + return usePreloadedQuery<${name}>(node, preloadedQuery, options) +}`; +} + +function makeQueryLoaderBlock(name: string) { + const n = capitalize(name); + return `export function use${n}Loader(initialQueryReference?: PreloadedQuery<${name}> | null) { + return useQueryLoader(node, initialQueryReference) +}`; +} + +function makeLazyLoadBlock( + name: string, + args: ReadonlyArray +) { + const n = capitalize(name); + const noVars = args.length === 0; + return `type LazyLoadOptions = Parameters[2]; + +export function use${n}(variables: VariablesOf<${name}>${ + noVars ? " = {}" : "" + }, options?: LazyLoadOptions) { + return useLazyLoadQuery<${name}>(node, variables, options) +}`; +} + +function makeLoadBlock( + name: string, + args: ReadonlyArray +) { + const n = capitalize(name); + const noVars = args.length === 0; + return `export function load${n}( + environment: IEnvironment, + variables: VariablesOf<${name}>${noVars ? " = {}" : ""}, + options?: LoadQueryOptions, + environmentProviderOptions?: TEnvironmentProviderOptions, +): PreloadedQuery<${name}, TEnvironmentProviderOptions> { + return loadQuery(environment, node, variables, options, environmentProviderOptions) +}`; +} + +function makeSubscriptionBlock( + name: string, + args: ReadonlyArray +) { + const n = capitalize(name); + const noVars = args.length === 0; + return `export function use${n}( + config${noVars ? "?:" : ":"} Omit< + GraphQLSubscriptionConfig<${name}>, + 'subscription' ${noVars ? `| 'variables'` : ""} + >${noVars ? `& { variables?: ${name}['variables'] },` : ","} + requestSubscriptionFn?: typeof requestSubscription +) { + const memoConfig = useMemo(() => { + return { + variables: ${noVars ? "{}" : "config.variables"}, + ...config, + subscription: node, + } + }, [config]); + return useSubscription<${name}>( + memoConfig, + requestSubscriptionFn + ); +}`; +} + +function makeMutationBlock(name: string) { + const n = capitalize(name); + return ` +export function use${n}(mutationConfig?: (environment: IEnvironment, config: MutationConfig<${name}>) => Disposable) { + return useMutation<${name}>(node, mutationConfig) +}`; +} diff --git a/yarn.lock b/yarn.lock index f370c075..d7cb66ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -966,10 +966,10 @@ graphql "^14.5.3" typescript "^3.0.0" -"@types/relay-runtime@*", "@types/relay-runtime@^10.0.1": - version "10.1.8" - resolved "https://registry.yarnpkg.com/@types/relay-runtime/-/relay-runtime-10.1.8.tgz#dc60ca3332693b952e5e2871718320a2377b466e" - integrity sha512-8oZA8RGHD7590ObCt0aWZSWIS5GYwcJIp70FovVu9mIHP7rmpVH2u0y29nJZ5TuSEbPNz6zy09IBMWjaHMruZg== +"@types/relay-runtime@*", "@types/relay-runtime@^10.1.10": + version "10.1.10" + resolved "https://registry.yarnpkg.com/@types/relay-runtime/-/relay-runtime-10.1.10.tgz#6d4da929129d7ad39c4932ca36bb5281bf301729" + integrity sha512-IyEqvYzMdNcsvhjIVsCvbwQsYT2KMpZrZcw4hQu+2k2MsQNmFSwN0ycWi1Q861qNKMv2rmcMn39O7xtoNhZ8Eg== "@types/stack-utils@^2.0.0": version "2.0.0" @@ -1212,10 +1212,10 @@ babel-plugin-macros@^2.0.0: cosmiconfig "^6.0.0" resolve "^1.12.0" -babel-plugin-relay@^10.0.0: - version "10.1.3" - resolved "https://registry.yarnpkg.com/babel-plugin-relay/-/babel-plugin-relay-10.1.3.tgz#3c52f06869c2295766f4df63736b9ad887386af3" - integrity sha512-2dD9J6lRAT9jOWD6WqMKSdpZ5inoRlyOnEEVGoRVQ9jVR6ktO+KDgigS/+sfbg8GIY+3Xb/meC1cegdI5evrSA== +babel-plugin-relay@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/babel-plugin-relay/-/babel-plugin-relay-11.0.0.tgz#82c6049bd94d84ebddc3b63ffe4fb4baceb90d39" + integrity sha512-pTNnFK5R7j8QxNZTx6w2TtV7MDL83+ncsGY9GOy8HdbAWpwQNFO1nRCUrLF1UrByHF2ARei+0yCNM+gxI8ezwg== dependencies: babel-plugin-macros "^2.0.0" @@ -3907,10 +3907,10 @@ regex-not@^1.0.0, regex-not@^1.0.2: extend-shallow "^3.0.2" safe-regex "^1.1.0" -relay-compiler@10.1.3, relay-compiler@^10.0.1: - version "10.1.3" - resolved "https://registry.yarnpkg.com/relay-compiler/-/relay-compiler-10.1.3.tgz#76ec4fd5d16f5b6aae61183f64b5caaef27f2525" - integrity sha512-AJoET3U8PrLXiA1/jmcr5beR/928+8c8qf46nOuumaNXhTfJC2RvN2fpD0APF0Fti+oKBxwKcQJ93R5BK6A2xw== +relay-compiler@11.0.0, relay-compiler@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/relay-compiler/-/relay-compiler-11.0.0.tgz#265bded552b0938ad597ea14b9488e3d818b99cf" + integrity sha512-xAVcnWBNtkIJqRwae2agY+riDhh00bV/HqwbcBYijK/S9jKPEFLx9FguGG1V8EWgS/barBsBMtE7CG916GtSrA== dependencies: "@babel/core" "^7.0.0" "@babel/generator" "^7.5.0" @@ -3925,27 +3925,27 @@ relay-compiler@10.1.3, relay-compiler@^10.0.1: glob "^7.1.1" immutable "~3.7.6" nullthrows "^1.1.1" - relay-runtime "10.1.3" + relay-runtime "11.0.0" signedsource "^1.0.0" yargs "^15.3.1" -relay-runtime@10.1.3, relay-runtime@^10.0.1: - version "10.1.3" - resolved "https://registry.yarnpkg.com/relay-runtime/-/relay-runtime-10.1.3.tgz#1d7bfe590b50cd7f18f6d1c0f20df71fe5bb451a" - integrity sha512-NSh4CaRRpUaziK72h4T5uKw6rvHovmS/xD9+czqUYg6yKv22ajwBE6SWmjwTSKnt2NBzIfDjh2C3heo9pLbvtg== +relay-runtime@11.0.0, relay-runtime@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/relay-runtime/-/relay-runtime-11.0.0.tgz#cb3244dc4e2919a51fdcbedddd5d13a20cf393e8" + integrity sha512-7oeyW4hulyK3p4eB63Rsllo/es83xflCAt2HMWCpH2q0fi21iJBR02kK3wCPM/yFwi3i0K83W+UksLFQdE0CaQ== dependencies: "@babel/runtime" "^7.0.0" fbjs "^3.0.0" -relay-test-utils-internal@^10.0.1: - version "10.1.3" - resolved "https://registry.yarnpkg.com/relay-test-utils-internal/-/relay-test-utils-internal-10.1.3.tgz#6e3f09f168b780753506b35503fb32e8a71e7c25" - integrity sha512-XdvGvsQc6+1TQUuu0oe5OYSHujZpfZauyeR3QnTeexCMYy4gqWysF+d39qm5+oPBU6P8BUckFwCLKG48EVi0nQ== +relay-test-utils-internal@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/relay-test-utils-internal/-/relay-test-utils-internal-11.0.0.tgz#8f22ccbe0c53fd9638d2419d4d199a00f5645b01" + integrity sha512-Vn0zX2g5eS5R42S4Ntd5H5xvSWJvB0PDR4B4BC2+nmIWOOLqqldc4KNBS3y3eebZ1ZwwyvAFsF+KgnE7GCTG3g== dependencies: "@babel/runtime" "^7.0.0" fbjs "^3.0.0" - relay-compiler "10.1.3" - relay-runtime "10.1.3" + relay-compiler "11.0.0" + relay-runtime "11.0.0" remove-trailing-separator@^1.0.1: version "1.1.0"