Skip to content

Commit 1cb7eba

Browse files
authored
feat(graphql-language-service): support GraphQL 17 fragment arguments (#4462)
Revives #3761 now that GraphQL.js 17 ships experimental fragment argument support. ## What changed - parse fragment variable definitions and fragment spread arguments in the online parser - enable GraphQL.js 17's `experimentalFragmentArguments` parser option across diagnostics, outlines, operation facts, fragment dependencies, document mode detection, and external fragments - resolve fragment variable definitions into language-service type information - autocomplete fragment argument names and values, including incomplete documents and external fragments - keep variable completion scope-aware across operations and fragments, including fragment-local shadowing and transitively reachable operations - show fragment argument type hover information - validate fragment arguments with GraphQL.js 17's specified rules - add a dedicated GraphQL 17 CI job and a release changeset
1 parent 296214d commit 1cb7eba

39 files changed

Lines changed: 974 additions & 172 deletions

.changeset/tidy-fragments-argue.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'graphql-language-service': minor
3+
'graphql-language-service-server': minor
4+
'monaco-graphql': minor
5+
'@graphiql/react': minor
6+
'graphiql': minor
7+
'codemirror-graphql': patch
8+
'cm6-graphql': patch
9+
---
10+
11+
Add opt-in GraphQL 17 fragment argument syntax support to parsing, validation,
12+
type information, autocomplete, hover, and editor integrations. Enable it with
13+
`experimentalFragmentArguments: true`; it defaults to `false` until server
14+
capabilities can advertise support.
15+
16+
Also fix variable autocomplete to respect operation and fragment scope while
17+
including variables from operations that spread the current fragment.

.github/workflows/pr.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,24 @@ jobs:
7878
path: ${{ env.BUILD-CACHE-LIST }}
7979
- run: yarn test
8080

81+
graphql-17-fragment-arguments:
82+
name: GraphQL 17 Fragment Arguments
83+
runs-on: ubuntu-latest
84+
steps:
85+
- uses: actions/checkout@v6
86+
- uses: actions/setup-node@v6
87+
with:
88+
node-version-file: '.node-version'
89+
cache: yarn
90+
- run: yarn install --immutable
91+
- run: yarn workspace graphql-language-service add --dev graphql@17.0.2
92+
- name: Build and typecheck graphql-language-service
93+
run: yarn workspace graphql-language-service exec tsc
94+
- name: Test fragment argument integration
95+
run: >-
96+
yarn vitest run packages/graphql-language-service/src/parser/__tests__/OnlineParser.test.ts packages/graphql-language-service/src/interface/__tests__/getAutocompleteSuggestions.test.ts packages/graphql-language-service/src/interface/__tests__/getDiagnostics.test.ts packages/graphql-language-service/src/interface/__tests__/getHoverInformation.test.ts
97+
98+
8199
lint:
82100
name: Lint
83101
runs-on: ubuntu-latest

packages/cm6-graphql/src/lint.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,14 @@ export const lint: Extension = linter(
3939
},
4040
];
4141
}
42-
const results = getDiagnostics(view.state.doc.toString(), schema);
42+
const results = getDiagnostics(
43+
view.state.doc.toString(),
44+
schema,
45+
undefined,
46+
undefined,
47+
undefined,
48+
options?.autocompleteOptions,
49+
);
4350

4451
return results
4552
.map((item): Diagnostic | null => {

packages/codemirror-graphql/src/lint.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99

1010
import CodeMirror from 'codemirror';
1111
import { FragmentDefinitionNode, GraphQLSchema, ValidationRule } from 'graphql';
12-
import { getDiagnostics } from 'graphql-language-service';
12+
import {
13+
getDiagnostics,
14+
GraphQLLanguageServiceOptions,
15+
} from 'graphql-language-service';
1316

1417
const SEVERITY = ['error', 'warning', 'information', 'hint'];
1518
const TYPE: Record<string, string> = {
@@ -22,6 +25,7 @@ interface GraphQLLintOptions {
2225
schema?: GraphQLSchema;
2326
validationRules: ValidationRule[];
2427
externalFragments?: string | FragmentDefinitionNode[];
28+
languageServiceOptions?: GraphQLLanguageServiceOptions;
2529
}
2630

2731
/**
@@ -42,13 +46,19 @@ CodeMirror.registerHelper(
4246
'lint',
4347
'graphql',
4448
(text: string, options: GraphQLLintOptions): CodeMirror.Annotation[] => {
45-
const { schema, validationRules, externalFragments } = options;
49+
const {
50+
schema,
51+
validationRules,
52+
externalFragments,
53+
languageServiceOptions,
54+
} = options;
4655
const rawResults = getDiagnostics(
4756
text,
4857
schema,
4958
validationRules,
5059
undefined,
5160
externalFragments,
61+
languageServiceOptions,
5262
);
5363

5464
const results = rawResults.map(error => ({

packages/graphiql-react/src/components/operation-editor.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export const OperationEditor: FC<OperationEditorProps> = ({
8282
),
8383
);
8484
const ref = useRef<HTMLDivElement>(null!);
85+
const { monacoGraphQL, monaco } = useMonaco();
8586
const onClickReferenceRef = useRef<OperationEditorProps['onClickReference']>(
8687
null!,
8788
);
@@ -158,7 +159,14 @@ export const OperationEditor: FC<OperationEditorProps> = ({
158159
*/
159160

160161
function getAndUpdateOperationFacts(editorInstance: MonacoEditor) {
161-
const operationFacts = getOperationFacts(schema, editorInstance.getValue());
162+
const operationFacts = getOperationFacts(
163+
schema,
164+
editorInstance.getValue(),
165+
{
166+
experimentalFragmentArguments:
167+
monacoGraphQL?.experimentalFragmentArguments,
168+
},
169+
);
162170
// Update the operation name should any query names change.
163171
const newOperationName = getSelectedOperationName(
164172
operations,
@@ -206,8 +214,6 @@ export const OperationEditor: FC<OperationEditorProps> = ({
206214
};
207215
}, [operationName, operations, run, setOperationName]);
208216

209-
const { monacoGraphQL, monaco } = useMonaco();
210-
211217
useEffect(() => {
212218
if (!monaco || !monacoGraphQL) {
213219
return;

packages/graphiql-react/src/components/provider.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ interface GraphiQLProviderProps
5757
ThemeProps,
5858
StorageProps {
5959
children: ReactNode;
60+
/** Enable experimental fragment arguments in the operation editor. */
61+
experimentalFragmentArguments?: boolean;
6062
}
6163

6264
type GraphiQLStore = UseBoundStore<StoreApi<SlicesWithActions>>;
@@ -128,7 +130,9 @@ useEffect(() => {
128130
const [mounted, setMounted] = useState(false);
129131

130132
useEffect(() => {
131-
void actions.initialize();
133+
void actions.initialize({
134+
experimentalFragmentArguments: props.experimentalFragmentArguments,
135+
});
132136
setMounted(true);
133137
}, []); // eslint-disable-line react-hooks/exhaustive-deps
134138

packages/graphiql-react/src/stores/monaco.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { createStore } from 'zustand';
2-
import type { MonacoGraphQLAPI } from 'monaco-graphql';
2+
import type {
3+
MonacoGraphQLAPI,
4+
MonacoGraphQLInitializeConfig,
5+
} from 'monaco-graphql';
36
import { createBoundedUseStore } from '../utility';
47
import {
58
JSON_DIAGNOSTIC_OPTIONS,
@@ -12,7 +15,12 @@ interface MonacoStoreType {
1215
monaco?: typeof import('monaco-editor');
1316
monacoGraphQL?: MonacoGraphQLAPI;
1417
actions: {
15-
initialize: () => Promise<void>;
18+
initialize: (
19+
options?: Pick<
20+
MonacoGraphQLInitializeConfig,
21+
'experimentalFragmentArguments'
22+
>,
23+
) => Promise<void>;
1624
};
1725
}
1826

@@ -65,7 +73,7 @@ async function patchFirefox() {
6573
*/
6674
export const monacoStore = createStore<MonacoStoreType>((set, get) => ({
6775
actions: {
68-
async initialize() {
76+
async initialize(options) {
6977
const isInitialized = Boolean(get().monaco);
7078
if (isInitialized) {
7179
return;
@@ -88,6 +96,7 @@ export const monacoStore = createStore<MonacoStoreType>((set, get) => ({
8896
}
8997
const monacoGraphQL = initializeMode({
9098
diagnosticSettings: MONACO_GRAPHQL_DIAGNOSTIC_SETTINGS,
99+
...options,
91100
});
92101
set({ monaco, monacoGraphQL });
93102
},

packages/graphiql/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ const root = createRoot(document.getElementById('root'));
117117
root.render(<GraphiQL fetcher={fetcher} />);
118118
```
119119

120+
When the connected server supports GraphQL.js 17 fragment arguments, opt in
121+
with `<GraphiQL fetcher={fetcher} experimentalFragmentArguments />`. The syntax
122+
is disabled by default.
123+
120124
## Customize
121125

122126
GraphiQL supports customization in UI and behavior by accepting React props and

packages/graphql-language-service-server/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,8 @@ module.exports = {
167167
enableValidation: true,
168168
// (experimental) enhanced auto expansion of graphql leaf fields and arguments
169169
fillLeafsOnComplete: true,
170+
// enable GraphQL.js 17 experimental fragment arguments across language features
171+
experimentalFragmentArguments: true,
170172
// instead of jumping directly to the SDL file, you can override definition peek/jump results to point to different files or locations
171173
// (for example, source files for your schema in any language!)
172174
// based on Relay vscode's pathToLocateCommand

packages/graphql-language-service-server/src/GraphQLCache.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
parse,
1919
visit,
2020
} from 'graphql';
21+
import type { ParseOptions } from 'graphql';
2122
import type {
2223
CachedContent,
2324
GraphQLFileMetadata,
@@ -162,6 +163,17 @@ export class GraphQLCache {
162163

163164
getGraphQLConfig = (): GraphQLConfig => this._graphQLConfig;
164165

166+
_parseGraphQL = (query: string, filePath?: Uri): DocumentNode => {
167+
const projectConfig = filePath
168+
? this.getProjectForFile(filePath)
169+
: undefined;
170+
return parse(query, {
171+
experimentalFragmentArguments:
172+
projectConfig?.extensions?.languageService
173+
?.experimentalFragmentArguments === true,
174+
} as ParseOptions);
175+
};
176+
165177
getProjectForFile = (uri: string): GraphQLProjectConfig | void => {
166178
try {
167179
const project = this._graphQLConfig.getProjectForFile(
@@ -184,6 +196,7 @@ export class GraphQLCache {
184196
getFragmentDependencies = async (
185197
query: string,
186198
fragmentDefinitions?: Map<string, FragmentInfo> | null,
199+
experimentalFragmentArguments = false,
187200
): Promise<FragmentInfo[]> => {
188201
// If there isn't context for fragment references,
189202
// return an empty array.
@@ -194,7 +207,9 @@ export class GraphQLCache {
194207
// Return an empty array.
195208
let parsedQuery;
196209
try {
197-
parsedQuery = parse(query);
210+
parsedQuery = parse(query, {
211+
experimentalFragmentArguments,
212+
} as ParseOptions);
198213
} catch {
199214
return [];
200215
}
@@ -452,7 +467,7 @@ export class GraphQLCache {
452467
const asts = contents.map(({ query }) => {
453468
try {
454469
return {
455-
ast: parse(query),
470+
ast: this._parseGraphQL(query, filePath),
456471
query,
457472
};
458473
} catch {
@@ -507,7 +522,7 @@ export class GraphQLCache {
507522
const asts = contents.map(({ query }) => {
508523
try {
509524
return {
510-
ast: parse(query),
525+
ast: this._parseGraphQL(query, filePath),
511526
query,
512527
};
513528
} catch {
@@ -821,7 +836,7 @@ export class GraphQLCache {
821836
}
822837

823838
for (const { query } of queries) {
824-
asts.push(parse(query));
839+
asts.push(this._parseGraphQL(query, filePath));
825840
}
826841
return {
827842
filePath,

0 commit comments

Comments
 (0)