Skip to content

Commit cb1b9d9

Browse files
authored
CODEGEN-866 [CLI] Fix generated files disappearing in watch mode (#10468)
* Fix typing * Update typing for devX * Update watcher to not write output on error, and add tests * Add changeset * Update tests to be less flaky
1 parent 766c464 commit cb1b9d9

File tree

5 files changed

+103
-10
lines changed

5 files changed

+103
-10
lines changed

.changeset/solid-oranges-shave.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@graphql-codegen/cli': patch
3+
---
4+
5+
In watch mode, do not write output on failure
6+
7+
Previously, on partial or full failure, watch mode still write to output. However, since the output'd be an empty array, it will then call `removeStaleFiles` internally to remove all previously generated files.
8+
9+
This patch puts a temporary fix to avoid writing output on any failure to fix the described behaviour.
10+
11+
This also means the `config.allowPartialOutputs` does not work in watch mode for now.

packages/graphql-codegen-cli/src/generate-and-save.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@ const hash = (content: string): string => createHash('sha1').update(content).dig
1515
export async function generate(
1616
input: CodegenContext | (Types.Config & { cwd?: string }),
1717
saveToFile = true
18-
): Promise<Types.FileOutput[] | any> {
18+
): Promise<
19+
| Types.FileOutput[]
20+
/**
21+
* When this function runs in watch mode, it'd return an empty promise that doesn't resolve until the watcher exits
22+
* FIXME: this effectively makes the result `any`, which loses type-hints
23+
*/
24+
| any
25+
> {
1926
const context = ensureContext(input);
2027
const config = context.getConfig();
2128
await context.profiler.run(() => lifecycleHooks(config.hooks).afterStart(), 'Lifecycle: afterStart');
@@ -43,7 +50,7 @@ export async function generate(
4350

4451
const recentOutputHash = new Map<string, string>();
4552

46-
async function writeOutput(generationResult: Types.FileOutput[]) {
53+
async function writeOutput(generationResult: Types.FileOutput[]): Promise<Types.FileOutput[]> {
4754
if (!saveToFile) {
4855
return generationResult;
4956
}

packages/graphql-codegen-cli/src/utils/watcher.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,21 @@ export const createWatcher = (
7979
if (!isShutdown) {
8080
executeCodegen(initialContext)
8181
.then(
82-
({ result }) => onNext(result),
82+
({ result, error }) => {
83+
// FIXME: this is a quick fix to stop `onNext` (writeOutput) from
84+
// removing all files when there is an error.
85+
//
86+
// This is because `removeStaleFiles()` will remove files if the
87+
// generated files are different between runs. And on error, it
88+
// returns an empty array i.e. will remove all generated files from
89+
// the previous run
90+
//
91+
// This also means we don't have config.allowPartialOutputs in watch mode
92+
if (error) {
93+
return;
94+
}
95+
onNext(result);
96+
},
8397
() => Promise.resolve()
8498
)
8599
.then(() => emitWatching(watchDirectory));
@@ -202,7 +216,14 @@ export const createWatcher = (
202216
stopWatching.runningWatcher = new Promise<void>((resolve, reject) => {
203217
executeCodegen(initialContext)
204218
.then(
205-
({ result }) => onNext(result),
219+
({ result, error }) => {
220+
// TODO: this is the initial run, the logic here mimics the above watcher logic.
221+
// We need to check whether it's ok to deviate between these two.
222+
if (error) {
223+
return;
224+
}
225+
onNext(result);
226+
},
206227
() => Promise.resolve()
207228
)
208229
.then(() => runWatcher(abortController.signal))

packages/graphql-codegen-cli/tests/watcher-test-helpers/assert-watcher-build-triggers.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { join, isAbsolute, relative, resolve, sep } from 'path';
2-
import { Options } from '@parcel/watcher';
2+
import type { Options } from '@parcel/watcher';
33
import isGlob from 'is-glob';
44
import type { Mock } from 'vitest';
55

@@ -22,7 +22,7 @@ interface MockWatcher {
2222

2323
/**
2424
* Helper function for asserting that multiple paths did or did not trigger a build,
25-
* and for asserting the values of paths and globs passed to {@link ParcelWatcher.Options}`["ignore"]`
25+
* and for asserting the values of paths and globs passed to {@link Options}`["ignore"]`
2626
*/
2727
export const assertBuildTriggers = async (
2828
mockWatcher: MockWatcher,
@@ -224,7 +224,7 @@ const assertParcelWouldIgnoreGlob = (
224224
};
225225

226226
/**
227-
* Given a path, and the `ignore` option passed to the mocked {@link ParcelWatcher.Options},
227+
* Given a path, and the `ignore` option passed to the mocked {@link Options},
228228
* assert that ParcelWatcher "would" ignore the path if given it as part of the ignore option.
229229
*
230230
* Note that ParcelWatcher expects paths relative from the watchDirectory, but

packages/graphql-codegen-cli/tests/watcher.spec.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const unsubscribeMock = vi.fn();
1010
const subscribeMock = vi.fn();
1111
let subscribeCallbackMock: Mock<SubscribeCallback>;
1212

13+
// FIXME: this mocks out the main functionality which is triggering the codegen
14+
// This is not great because we cannot test the actual watch functionality
1315
vi.mock('@parcel/watcher', () => ({
1416
subscribe: subscribeMock.mockImplementation((watchDirectory: string, subscribeCallback: SubscribeCallback) => {
1517
subscribeCallbackMock = vi.fn(subscribeCallback);
@@ -19,13 +21,16 @@ vi.mock('@parcel/watcher', () => ({
1921
}),
2022
}));
2123

22-
const setupMockWatcher = async (codegenContext: ConstructorParameters<typeof CodegenContext>[0]) => {
23-
const { stopWatching } = createWatcher(new CodegenContext(codegenContext), async () => Promise.resolve([]));
24+
const setupMockWatcher = async (
25+
codegenContext: ConstructorParameters<typeof CodegenContext>[0],
26+
onNext: Mock = vi.fn().mockResolvedValue([])
27+
) => {
28+
const { stopWatching } = createWatcher(new CodegenContext(codegenContext), onNext);
2429

2530
const dispatchChange = async (path: string) => subscribeCallbackMock(undefined, [{ type: 'update', path }]);
2631

2732
// createWatcher doesn't set up subscription immediately, so we wait for a tick before continuing
28-
await new Promise(resolve => setTimeout(resolve, 10));
33+
await new Promise(resolve => setTimeout(resolve, 100));
2934

3035
return { stopWatching, dispatchChange };
3136
};
@@ -771,4 +776,53 @@ describe('Watch targets', () => {
771776
}
772777
);
773778
});
779+
780+
test('it does not call onNext on error', async () => {
781+
vi.spyOn(fs, 'access').mockImplementation(() => Promise.resolve());
782+
const onNextMock = vi.fn();
783+
784+
const schema = /* GraphQL */ `
785+
type Query {
786+
me: User
787+
}
788+
789+
type User {
790+
id: ID
791+
}
792+
`;
793+
const document = /* GraphQL */ `
794+
query {
795+
me {
796+
id
797+
zzz # Error here
798+
}
799+
}
800+
`;
801+
802+
const { stopWatching } = await setupMockWatcher(
803+
{
804+
filepath: './foo/some-config.ts',
805+
config: {
806+
hooks: { onWatchTriggered: vi.fn() },
807+
schema,
808+
documents: document,
809+
generates: {
810+
['./foo/some-output.ts']: {
811+
plugins: ['typescript'],
812+
},
813+
},
814+
},
815+
},
816+
onNextMock
817+
);
818+
819+
// Because document has error, onNext shouldn't be called
820+
expect(onNextMock).not.toHaveBeenCalled();
821+
822+
// Wait a tick for stopWatch to be set up correctly, before calling it
823+
await new Promise(resolve => setTimeout(resolve, 100));
824+
await stopWatching();
825+
});
826+
827+
test.todo('on watcher subsequent codegen run, it does not call onNext on error');
774828
});

0 commit comments

Comments
 (0)