diff --git a/.changeset/spotty-sheep-sing.md b/.changeset/spotty-sheep-sing.md new file mode 100644 index 00000000000..fe7689ea9ad --- /dev/null +++ b/.changeset/spotty-sheep-sing.md @@ -0,0 +1,5 @@ +--- +'@graphql-codegen/cli': minor +--- + +Update watcher to only re-runs generation for blocks with affected watched files diff --git a/packages/graphql-codegen-cli/src/codegen.ts b/packages/graphql-codegen-cli/src/codegen.ts index 3eaffe8919e..50d93fe138f 100644 --- a/packages/graphql-codegen-cli/src/codegen.ts +++ b/packages/graphql-codegen-cli/src/codegen.ts @@ -70,7 +70,8 @@ function createCache(): (namespace: string, key: string, factory: () => Promi } export async function executeCodegen( - input: CodegenContext | Types.Config + input: CodegenContext | Types.Config, + options: { onlyGeneratesKeys: Record } = { onlyGeneratesKeys: {} } ): Promise<{ result: Types.FileOutput[]; error: Error | null }> { const context = ensureContext(input); const config = context.getConfig(); @@ -197,7 +198,17 @@ export async function executeCodegen( { title: 'Generate outputs', task: (ctx, task) => { - const generateTasks: ListrTask[] = Object.keys(generates).map(filename => { + const originalGeneratesKeys = Object.keys(generates); + const foundGeneratesKeys = originalGeneratesKeys.filter( + generatesKey => options.onlyGeneratesKeys[generatesKey] + ); + const effectiveGeneratesKeys = foundGeneratesKeys.length === 0 ? originalGeneratesKeys : foundGeneratesKeys; + const hasFilteredDownGeneratesKeys = originalGeneratesKeys.length > effectiveGeneratesKeys.length; + if (hasFilteredDownGeneratesKeys) { + debugLog(`[CLI] Generating partial config:\n${effectiveGeneratesKeys.map(key => `- ${key}`).join('\n')}`); + } + + const generateTasks: ListrTask[] = effectiveGeneratesKeys.map(filename => { const outputConfig = generates[filename]; const hasPreset = !!outputConfig.preset; diff --git a/packages/graphql-codegen-cli/src/utils/patterns.ts b/packages/graphql-codegen-cli/src/utils/patterns.ts index b584a3af58a..e2518c2f6be 100644 --- a/packages/graphql-codegen-cli/src/utils/patterns.ts +++ b/packages/graphql-codegen-cli/src/utils/patterns.ts @@ -39,15 +39,18 @@ export const allAffirmativePatternsFromPatternSets = (patternSets: PatternSet[]) * a match even if it would be negated by some pattern in documents or schemas * * The trigger returns true if any output target's local patterns result in * a match, after considering the precedence of any global and local negations + * + * The result is a function that when given an absolute path, + * it will tell which generates blocks' keys are affected so we can re-run for those keys */ export const makeShouldRebuild = ({ globalPatternSet, localPatternSets, }: { globalPatternSet: PatternSet; - localPatternSets: PatternSet[]; -}) => { - const localMatchers = localPatternSets.map(localPatternSet => { + localPatternSets: Record; +}): ((params: { path: string }) => Record) => { + const localMatchers = Object.entries(localPatternSets).map(([generatesPath, localPatternSet]) => { return (path: string) => { // Is path negated by any negating watch pattern? if (matchesAnyNegatedPattern(path, [...globalPatternSet.watch.negated, ...localPatternSet.watch.negated])) { @@ -63,7 +66,7 @@ export const makeShouldRebuild = ({ ]) ) { // Immediately return true: Watch pattern takes priority, even if documents or schema would negate it - return true; + return generatesPath; } // Does path match documents patterns (without being negated)? @@ -74,7 +77,7 @@ export const makeShouldRebuild = ({ ]) && !matchesAnyNegatedPattern(path, [...globalPatternSet.documents.negated, ...localPatternSet.documents.negated]) ) { - return true; + return generatesPath; } // Does path match schemas patterns (without being negated)? @@ -85,7 +88,7 @@ export const makeShouldRebuild = ({ ]) && !matchesAnyNegatedPattern(path, [...globalPatternSet.schemas.negated, ...localPatternSet.schemas.negated]) ) { - return true; + return generatesPath; } // Otherwise, there is no match @@ -93,17 +96,22 @@ export const makeShouldRebuild = ({ }; }); - /** - * Return `true` if `path` should trigger a rebuild - */ - return ({ path: absolutePath }: { path: string }) => { + return ({ path: absolutePath }) => { if (!isAbsolute(absolutePath)) { throw new Error('shouldRebuild trigger should be called with absolute path'); } const path = relative(process.cwd(), absolutePath); - const shouldRebuild = localMatchers.some(matcher => matcher(path)); - return shouldRebuild; + + const generatesKeysToRebuild: Record = {}; + for (const matcher of localMatchers) { + const result = matcher(path); + if (result) { + generatesKeysToRebuild[result] = true; + } + } + + return generatesKeysToRebuild; }; }; @@ -137,7 +145,7 @@ export const makeGlobalPatternSet = (initialContext: CodegenContext) => { * patterns will be mixed into the pattern set of their respective gobal pattern * set equivalents. */ -export const makeLocalPatternSet = (conf: Types.ConfiguredOutput) => { +export const makeLocalPatternSet = (conf: Types.ConfiguredOutput): PatternSet => { return { watch: sortPatterns(normalizeInstanceOrArray(conf.watchPattern)), documents: sortPatterns( @@ -216,7 +224,7 @@ type SortedPatterns normalizeOutputParam(config.generates[filename])) - .map(conf => makeLocalPatternSet(conf)); - const allAffirmativePatterns = allAffirmativePatternsFromPatternSets([globalPatternSet, ...localPatternSets]); + + const localPatternSetArray: PatternSet[] = []; + const localPatternSets = Object.entries(config.generates).reduce>( + (res, [filename, conf]) => { + const patternSet = makeLocalPatternSet(normalizeOutputParam(conf)); + res[filename] = patternSet; + localPatternSetArray.push(patternSet); + return res; + }, + {} + ); + + const allAffirmativePatterns = allAffirmativePatternsFromPatternSets([globalPatternSet, ...localPatternSetArray]); const shouldRebuild = makeShouldRebuild({ globalPatternSet, localPatternSets }); @@ -75,9 +85,9 @@ export const createWatcher = ( let isShutdown = false; - const debouncedExec = debounce(() => { + const debouncedExec = debounce((generatesKeysToRebuild: Record) => { if (!isShutdown) { - executeCodegen(initialContext) + executeCodegen(initialContext, { onlyGeneratesKeys: generatesKeysToRebuild }) .then( ({ result, error }) => { // FIXME: this is a quick fix to stop `onNext` (writeOutput) from @@ -123,11 +133,12 @@ export const createWatcher = ( watcherSubscription = await parcelWatcher.subscribe( watchDirectory, async (_, events) => { - // it doesn't matter what has changed, need to run whole process anyway await Promise.all( // NOTE: @parcel/watcher always provides path as an absolute path events.map(async ({ type: eventName, path }) => { - if (!shouldRebuild({ path })) { + const generatesKeysToRebuild = shouldRebuild({ path }); + + if (Object.keys(generatesKeysToRebuild).length === 0) { return; } @@ -151,7 +162,7 @@ export const createWatcher = ( initialContext.updateConfig(config); } - debouncedExec(); + debouncedExec(generatesKeysToRebuild); }) ); }, diff --git a/packages/graphql-codegen-cli/tests/watcher.run.spec.ts b/packages/graphql-codegen-cli/tests/watcher.run.spec.ts index 116b529f83f..4fcf3225bdb 100644 --- a/packages/graphql-codegen-cli/tests/watcher.run.spec.ts +++ b/packages/graphql-codegen-cli/tests/watcher.run.spec.ts @@ -1,4 +1,3 @@ -import type { Mock } from 'vitest'; import * as path from 'path'; import { mkdtempSync, mkdirSync, writeFileSync } from 'fs'; import { createWatcher } from '../src/utils/watcher.js'; @@ -39,16 +38,12 @@ const setupTestFiles = (): { testDir: string; schemaFile: TestFilePaths; documen }; }; -const onNextMock = vi.fn(); - -const setupMockWatcher = async ( - codegenContext: ConstructorParameters[0], - onNext: Mock = vi.fn().mockResolvedValue([]) -) => { - const { stopWatching } = createWatcher(new CodegenContext(codegenContext), onNext); +const setupMockWatcher = async (codegenContext: ConstructorParameters[0]) => { + const onNextMock = vi.fn().mockResolvedValue([]); + const { stopWatching } = createWatcher(new CodegenContext(codegenContext), onNextMock); // After creating watcher, wait for a tick for subscription to be completely set up await waitForNextEvent(); - return { stopWatching }; + return { stopWatching, onNextMock }; }; describe('Watch runs', () => { @@ -77,22 +72,19 @@ describe('Watch runs', () => { } ` ); - await waitForNextEvent(); - const { stopWatching } = await setupMockWatcher( - { - filepath: path.join(testDir, 'codegen.ts'), - config: { - schema: schemaFile.relative, - documents: documentFile.relative, - generates: { - [path.join(testDir, 'types.ts')]: { - plugins: ['typescript'], - }, + + const { stopWatching, onNextMock } = await setupMockWatcher({ + filepath: path.join(testDir, 'codegen.ts'), + config: { + schema: schemaFile.relative, + documents: documentFile.relative, + generates: { + [path.join(testDir, 'types.ts')]: { + plugins: ['typescript'], }, }, }, - onNextMock - ); + }); // 1. Initial setup: onNext in initial run should be called because no errors expect(onNextMock).toHaveBeenCalledTimes(1); @@ -129,7 +121,97 @@ describe('Watch runs', () => { expect(onNextMock).toHaveBeenCalledTimes(2); await stopWatching(); + }); + + test('only re-runs generates processes based on watched path', async () => { + const { testDir, schemaFile, documentFile } = setupTestFiles(); + writeFileSync( + schemaFile.absolute, + /* GraphQL */ ` + type Query { + me: User + } + + type User { + id: ID! + name: String! + } + ` + ); + writeFileSync( + documentFile.absolute, + /* GraphQL */ ` + query { + me { + id + } + } + ` + ); + + const generatesKey1 = path.join(testDir, 'types-1.ts'); + const generatesKey2 = path.join(testDir, 'types-2.ts'); + + const { stopWatching, onNextMock } = await setupMockWatcher({ + filepath: path.join(testDir, 'codegen.ts'), + config: { + schema: schemaFile.relative, + generates: { + [generatesKey1]: { + plugins: ['typescript'], + }, + [generatesKey2]: { + documents: documentFile.relative, // When this file is changed, only this block will be re-generated + plugins: ['typescript'], + }, + }, + }, + }); + // 1. Initial setup: onNext in initial run should be called successfully with 2 files, + // because there are no errors + expect(onNextMock.mock.calls[0][0].length).toBe(2); + expect(onNextMock.mock.calls[0][0][0].filename).toBe(generatesKey1); + expect(onNextMock.mock.calls[0][0][1].filename).toBe(generatesKey2); + + // 2. Subsequent run 1: update document file for generatesKey2, + // so only the second generates block gets triggered + writeFileSync( + documentFile.absolute, + /* GraphQL */ ` + query { + me { + id + name + } + } + ` + ); await waitForNextEvent(); + expect(onNextMock.mock.calls[1][0].length).toBe(1); + expect(onNextMock.mock.calls[1][0][0].filename).toBe(generatesKey2); + + // 2. Subsequent run 2: update schema file, so both generates block are triggered + writeFileSync( + schemaFile.absolute, + /* GraphQL */ ` + type Query { + me: User + } + + type User { + id: ID! + name: String! + } + + scalar DateTime + ` + ); + await waitForNextEvent(); + expect(onNextMock.mock.calls[2][0].length).toBe(2); + expect(onNextMock.mock.calls[2][0][0].filename).toBe(generatesKey1); + expect(onNextMock.mock.calls[2][0][1].filename).toBe(generatesKey2); + + await stopWatching(); }); });