Skip to content

Commit 10b5aba

Browse files
Fix: detecting fragment usage in maskFragments() (#379)
Co-authored-by: Jovi De Croock <decroockjovi@gmail.com>
1 parent 2a16539 commit 10b5aba

File tree

8 files changed

+102
-2
lines changed

8 files changed

+102
-2
lines changed

.changeset/lucky-friends-beg.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@0no-co/graphqlsp': patch
3+
---
4+
5+
Detect fragment usage in `maskFragments` calls to prevent false positive unused fragment warnings

packages/graphqlsp/src/ast/checks.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,13 @@ export const getSchemaName = (
123123
}
124124
return null;
125125
};
126+
127+
/** Checks if node is a maskFragments() call */
128+
export const isMaskFragmentsCall = (
129+
node: ts.Node
130+
): node is ts.CallExpression => {
131+
if (!ts.isCallExpression(node)) return false;
132+
if (!ts.isIdentifier(node.expression)) return false;
133+
// Only checks function name, not whether it's from gql.tada
134+
return node.expression.escapedText === 'maskFragments';
135+
};

packages/graphqlsp/src/ast/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,21 @@ export function findAllImports(
327327
return sourceFile.statements.filter(ts.isImportDeclaration);
328328
}
329329

330+
export function findAllMaskFragmentsCalls(
331+
sourceFile: ts.SourceFile
332+
): Array<ts.CallExpression> {
333+
const result: Array<ts.CallExpression> = [];
334+
335+
function find(node: ts.Node): void {
336+
if (checks.isMaskFragmentsCall(node)) {
337+
result.push(node);
338+
}
339+
ts.forEachChild(node, find);
340+
}
341+
find(sourceFile);
342+
return result;
343+
}
344+
330345
export function bubbleUpTemplate(node: ts.Node): ts.Node {
331346
while (
332347
ts.isNoSubstitutionTemplateLiteral(node) ||

packages/graphqlsp/src/diagnostics.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
findAllCallExpressions,
1616
findAllPersistedCallExpressions,
1717
findAllTaggedTemplateNodes,
18+
findAllMaskFragmentsCalls,
1819
getSource,
1920
unrollFragment,
2021
} from './ast';
@@ -292,6 +293,7 @@ export function getGraphQLDiagnostics(
292293

293294
if (isCallExpression && shouldCheckForColocatedFragments) {
294295
const moduleSpecifierToFragments = getColocatedFragmentNames(source, info);
296+
const typeChecker = info.languageService.getProgram()?.getTypeChecker();
295297

296298
const usedFragments = new Set();
297299
nodes.forEach(({ node }) => {
@@ -307,6 +309,23 @@ export function getGraphQLDiagnostics(
307309
} catch (e) {}
308310
});
309311

312+
// check for maskFragments() calls
313+
const maskFragmentsCalls = findAllMaskFragmentsCalls(source);
314+
maskFragmentsCalls.forEach(call => {
315+
const firstArg = call.arguments[0];
316+
if (!firstArg) return;
317+
318+
// Handle array of fragments: maskFragments([Fragment1, Fragment2], data)
319+
if (ts.isArrayLiteralExpression(firstArg)) {
320+
firstArg.elements.forEach(element => {
321+
if (ts.isIdentifier(element)) {
322+
const fragmentDefs = unrollFragment(element, info, typeChecker);
323+
fragmentDefs.forEach(def => usedFragments.add(def.name.value));
324+
}
325+
});
326+
}
327+
});
328+
310329
Object.keys(moduleSpecifierToFragments).forEach(moduleSpecifier => {
311330
const {
312331
fragments: fragmentNames,

test/e2e/fixture-project-tada/fixtures/graphql.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@ export const graphql = initGraphQLTada<{
66
}>();
77

88
export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada';
9-
export { readFragment } from 'gql.tada';
9+
export { readFragment, maskFragments } from 'gql.tada';
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { maskFragments } from './graphql';
2+
import { Pokemon, PokemonFields } from './fragment';
3+
4+
const data = { id: '1', name: 'Pikachu', fleeRate: 0.1 };
5+
const x = maskFragments([PokemonFields], data);
6+
7+
console.log(Pokemon);

test/e2e/fixture-project-tada/graphql.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@ export const graphql = initGraphQLTada<{
66
}>();
77

88
export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada';
9-
export { readFragment } from 'gql.tada';
9+
export { readFragment, maskFragments } from 'gql.tada';

test/e2e/tada.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ describe('Fragment + operations', () => {
1212
const outfileCombo = path.join(projectPath, 'simple.ts');
1313
const outfileTypeCondition = path.join(projectPath, 'type-condition.ts');
1414
const outfileUnusedFragment = path.join(projectPath, 'unused-fragment.ts');
15+
const outfileUsedFragmentMask = path.join(
16+
projectPath,
17+
'used-fragment-mask.ts'
18+
);
1519
const outfileCombinations = path.join(projectPath, 'fragment.ts');
1620

1721
let server: TSServer;
@@ -38,6 +42,11 @@ describe('Fragment + operations', () => {
3842
fileContent: '// empty',
3943
scriptKindName: 'TS',
4044
} satisfies ts.server.protocol.OpenRequestArgs);
45+
server.sendCommand('open', {
46+
file: outfileUsedFragmentMask,
47+
fileContent: '// empty',
48+
scriptKindName: 'TS',
49+
} satisfies ts.server.protocol.OpenRequestArgs);
4150

4251
server.sendCommand('updateOpen', {
4352
openFiles: [
@@ -69,6 +78,13 @@ describe('Fragment + operations', () => {
6978
'utf-8'
7079
),
7180
},
81+
{
82+
file: outfileUsedFragmentMask,
83+
fileContent: fs.readFileSync(
84+
path.join(projectPath, 'fixtures/used-fragment-mask.ts'),
85+
'utf-8'
86+
),
87+
},
7288
],
7389
} satisfies ts.server.protocol.UpdateOpenRequestArgs);
7490

@@ -88,11 +104,16 @@ describe('Fragment + operations', () => {
88104
file: outfileUnusedFragment,
89105
tmpfile: outfileUnusedFragment,
90106
} satisfies ts.server.protocol.SavetoRequestArgs);
107+
server.sendCommand('saveto', {
108+
file: outfileUsedFragmentMask,
109+
tmpfile: outfileUsedFragmentMask,
110+
} satisfies ts.server.protocol.SavetoRequestArgs);
91111
});
92112

93113
afterAll(() => {
94114
try {
95115
fs.unlinkSync(outfileUnusedFragment);
116+
fs.unlinkSync(outfileUsedFragmentMask);
96117
fs.unlinkSync(outfileCombinations);
97118
fs.unlinkSync(outfileCombo);
98119
fs.unlinkSync(outfileTypeCondition);
@@ -386,6 +407,29 @@ List out all Pokémon, optionally in pages`
386407
`);
387408
}, 30000);
388409

410+
it('should not warn about unused fragments when using maskFragments', async () => {
411+
server.sendCommand('saveto', {
412+
file: outfileUsedFragmentMask,
413+
tmpfile: outfileUsedFragmentMask,
414+
} satisfies ts.server.protocol.SavetoRequestArgs);
415+
416+
await server.waitForResponse(
417+
e =>
418+
e.type === 'event' &&
419+
e.event === 'semanticDiag' &&
420+
e.body?.file === outfileUsedFragmentMask
421+
);
422+
423+
const res = server.responses.filter(
424+
resp =>
425+
resp.type === 'event' &&
426+
resp.event === 'semanticDiag' &&
427+
resp.body?.file === outfileUsedFragmentMask
428+
);
429+
// Should have no diagnostics about unused fragments since maskFragments uses them
430+
expect(res[0].body.diagnostics).toMatchInlineSnapshot(`[]`);
431+
}, 30000);
432+
389433
it('gives quick-info at start of word (#15)', async () => {
390434
server.send({
391435
seq: 11,

0 commit comments

Comments
 (0)