Skip to content
This repository has been archived by the owner on Sep 27, 2023. It is now read-only.

feat: generated auto-typed hooks #305

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require("./lib/hooks");
21 changes: 11 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
],
"main": "lib/index.js",
"files": [
"lib"
"lib",
"hooks.js"
],
"scripts": {
"build": "rm -rf lib && tsc --project tsconfig.build.json",
Expand Down Expand Up @@ -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",
Expand All @@ -94,20 +95,20 @@
"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",
"tslint-config-prettier": "^1.18.0",
"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",
Comment on lines +110 to +111
Copy link
Collaborator

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.

Copy link
Member

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

Copy link
Collaborator

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.

"typescript": ">=3.6.4"
},
"publishConfig": {
Expand Down
314 changes: 314 additions & 0 deletions src/hooks.ts
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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

derivedFrom is defined from Relay's split operation transformer that runs as a part of it's overall query transform operation. It's either the name of the node the referenced node is derivedFrom or undefined.

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 use{...}Query definition for it because that's already encapsulated in the fragment.

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 useTodoAppRefetchQuery hook... that's just an internal implementation detail of the const [data, refetch] = useTodoAppData(data) fragment hook.

And that's why nothing is output at the bottom of this file here:

https://github.com/tgriesser/relay-compiler-language-typescript/blob/c18a58f2cd2f82bb0c6f5b5bc6875779ff9d33e8/example-hooks-gen/ts/__relay_artifacts__/TodoAppRefetchQuery.graphql.ts#L410-L422

Copy link
Collaborator

Choose a reason for hiding this comment

The 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

@tgriesser tgriesser Mar 23, 2021

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator

Choose a reason for hiding this comment

The 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)
}`;
}
Loading