-
Notifications
You must be signed in to change notification settings - Fork 70
feat: generated auto-typed hooks #305
base: master
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
module.exports = require("./lib/hooks"); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,314 @@ | ||
import { FormatModule, LocalArgumentDefinition } from "relay-compiler"; | ||
import addAnyTypeCast from "./addAnyTypeCast"; | ||
import relayCompilerLanguageTypescript from "./index"; | ||
import { loadCompilerOptions } from "./loadCompilerOptions"; | ||
|
||
export default function relayHooksTypescriptCompiler() { | ||
const compilerOptions = loadCompilerOptions(); | ||
|
||
const formatModule: FormatModule = (opts) => { | ||
const { | ||
// moduleName, | ||
documentType, | ||
docText, | ||
concreteText, | ||
typeText, | ||
hash, | ||
sourceHash, | ||
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
So, if it's derived no hooks are generated. I get all that. I don't think I understand when it's classified as derived vs not. The transform looks like it runs on all operations. I guess only the base most nodes would be considered not derived? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IIRC I believe that this is was for refetchable fragments: https://github.com/facebook/relay/blob/d59a32bcdbc11341f972def8e5967408a2131c18/packages/relay-compiler/language/javascript/RelayFlowGenerator.js#L989-L1006 essentially if it's "derived" then it's the operation name that will be called when refetching, but we don't actually want to create a From the example app: fragment TodoAppData on User
@refetchable(queryName: "TodoAppRefetchQuery")
@argumentDefinitions(
last: { type: "Int" }
first: { type: "Int" }
after: { type: "String" }
before: { type: "String" }
) {
id
totalCount
isAppending
...TodoListFooterData
...TodoList
@arguments(last: $last, first: $first, after: $after, before: $before)
} We don't actually want to generate a And that's why nothing is output at the bottom of this file here: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cool, thanks! |
||
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")); | ||
} | ||
|
||
const docTextComment = docText ? "\n/*\n" + docText.trim() + "\n*/\n" : ""; | ||
let nodeStatement = `const node: ${ | ||
documentType || "never" | ||
} = ${concreteText};`; | ||
if (compilerOptions.noImplicitAny) { | ||
nodeStatement = addAnyTypeCast(nodeStatement).trim(); | ||
} | ||
return `/* tslint:disable */ | ||
/* eslint-disable */ | ||
// @ts-nocheck | ||
${hash ? `/* ${hash} */\n` : ""} | ||
${allImports.join("\n")} | ||
${typeText || ""} | ||
|
||
${docTextComment} | ||
${nodeStatement} | ||
(node as any).hash = '${sourceHash}'; | ||
|
||
export default node; | ||
|
||
${allHooks.join("\n")} | ||
`; | ||
}; | ||
|
||
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<TKey extends ${n}$key>(fragmentRef: TKey): Required<TKey>[" $data"] | ||
export function use${n}Fragment<TKey extends ${n}$key>(fragmentRef: TKey | null): Required<TKey>[" $data"] | null | ||
export function use${n}Fragment<TKey extends ${n}$key>(fragmentRef: ReadonlyArray<TKey>): ReadonlyArray<Required<TKey>[" $data"]> | ||
export function use${n}Fragment<TKey extends ${n}$key>(fragmentRef: ReadonlyArray<TKey | null>): ReadonlyArray<Required<TKey>[" $data"] | null> | ||
export function use${n}Fragment<TKey extends ${n}$key>(fragmentRef: ReadonlyArray<TKey> | null): ReadonlyArray<Required<TKey>[" $data"]> | null | ||
export function use${n}Fragment<TKey extends ${n}$key>(fragmentRef: ReadonlyArray<TKey | null> | null): ReadonlyArray<Required<TKey>[" $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<TKey extends ${n}$key>(fragmentRef: TKey): [Required<TKey>[" $data"], RefetchFnDynamic<${operation}, ${n}$key>] | ||
export function useRefetchable${n}Fragment<TKey extends ${n}$key>(fragmentRef: TKey | null): [Required<TKey>[" $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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Those DefinitelyTyped files are lying a bit 😆 - I suppose we could do import type { usePaginationFragmentHookType } from 'react-relay/relay-hooks/usePaginationFragment but that's technically not a valid import path from the js. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, yep. Makes sense. |
||
return `interface usePaginationFragmentHookType<TQuery extends OperationType, TKey extends ${name}$key | null, TFragmentData> { | ||
data: TFragmentData; | ||
loadNext: LoadMoreFn<TQuery>; | ||
loadPrevious: LoadMoreFn<TQuery>; | ||
hasNext: boolean; | ||
hasPrevious: boolean; | ||
isLoadingNext: boolean; | ||
isLoadingPrevious: boolean; | ||
refetch: RefetchFnDynamic<TQuery, TKey>; | ||
} | ||
|
||
export function usePaginated${n}<K extends ${name}$key>(fragmentRef: K): usePaginationFragmentHookType<${operation}, ${name}$key, Required<K>[" $data"]> | ||
export function usePaginated${n}<K extends ${name}$key>(fragmentRef: K | null): usePaginationFragmentHookType<${operation}, ${name}$key | null, Required<K>[" $data"] | null>; | ||
export function usePaginated${n}(fragmentRef: any) { | ||
return usePaginationFragment<${operation}, ${name}$key>(node, fragmentRef) | ||
}`; | ||
} | ||
|
||
function makePreloadedQueryBlock(name: string) { | ||
const n = capitalize(name); | ||
return `export function usePreloaded${n}(preloadedQuery: PreloadedQuery<${name}>, options?: { | ||
UNSTABLE_renderPolicy?: RenderPolicy; | ||
}) { | ||
return usePreloadedQuery<${name}>(node, preloadedQuery, options) | ||
}`; | ||
} | ||
|
||
function makeQueryLoaderBlock(name: string) { | ||
const fn = capitalize(name); | ||
return `export function use${fn}Loader(initialQueryReference?: PreloadedQuery<${name}> | null) { | ||
return useQueryLoader(node, initialQueryReference) | ||
}`; | ||
} | ||
|
||
function makeLazyLoadBlock( | ||
name: string, | ||
args: ReadonlyArray<LocalArgumentDefinition> | ||
) { | ||
const n = capitalize(name); | ||
const noVars = args.length === 0; | ||
return `export function use${n}(variables: VariablesOf<${name}>${ | ||
noVars ? " = {}" : "" | ||
}, options?: { | ||
fetchKey?: string | number; | ||
fetchPolicy?: FetchPolicy; | ||
networkCacheConfig?: CacheConfig; | ||
UNSTABLE_renderPolicy?: RenderPolicy; | ||
tgriesser marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}) { | ||
return useLazyLoadQuery<${name}>(node, variables, options) | ||
}`; | ||
} | ||
|
||
function makeLoadBlock( | ||
name: string, | ||
args: ReadonlyArray<LocalArgumentDefinition> | ||
) { | ||
const n = capitalize(name); | ||
const noVars = args.length === 0; | ||
return `export function load${n}<TEnvironmentProviderOptions extends EnvironmentProviderOptions = {}>( | ||
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<LocalArgumentDefinition> | ||
) { | ||
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) | ||
}`; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Based on this I'm assuming this change will likely need to be apart of a major version bump.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we can ship this and the remaining breaking changes I have in my branch as a new major version next month while also dropping node 10
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's true, yeah. Given that there's an extra entry point (a sort of opt-in if you will) I feel like we could likely loosen this requirement with the caveat of course that if you're using hooks version you'll need to be on v11. Not sure if it's worth encoding that as a dependency or runtime constraint vs just documenting it.