diff --git a/.changeset/sharp-fans-fly.md b/.changeset/sharp-fans-fly.md new file mode 100644 index 000000000..0fd59e82c --- /dev/null +++ b/.changeset/sharp-fans-fly.md @@ -0,0 +1,5 @@ +--- +'houdini': minor +--- + +Add autoCodeGen option to vite plugin. Use 'smart' to only trigger code generation when GraphQL documents change diff --git a/packages/houdini/src/lib/types.ts b/packages/houdini/src/lib/types.ts index f9a2a5f40..8110e8091 100644 --- a/packages/houdini/src/lib/types.ts +++ b/packages/houdini/src/lib/types.ts @@ -366,7 +366,11 @@ export type GenerateHookInput = { pluginRoot: string } -export type PluginConfig = { configPath?: string; adapter?: Adapter } & Partial +export type PluginConfig = { + configPath?: string + adapter?: Adapter + autoCodeGen?: 'smart' | 'off' | 'startup' | 'watch' +} & Partial export * from '../runtime/lib/types' export * from '../runtime/lib/config' diff --git a/packages/houdini/src/vite/document.test.ts b/packages/houdini/src/vite/document.test.ts new file mode 100644 index 000000000..baf4a1e3a --- /dev/null +++ b/packages/houdini/src/vite/document.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect } from 'vitest' + +import { extractGraphQLDocumentName, extractGraphQLStrings } from './documents' + +describe('extractGraphQLStrings', () => { + it('should extract simple double-quoted strings', () => { + const input = 'graphql("query { user { name } }")' + expect(extractGraphQLStrings(input)).toEqual(['query { user { name } }']) + }) + + it('should extract simple single-quoted strings', () => { + const input = "graphql('query { user { name } }')" + expect(extractGraphQLStrings(input)).toEqual(['query { user { name } }']) + }) + + it('should extract backtick strings', () => { + const input = 'graphql(`query { user { name } }`)' + expect(extractGraphQLStrings(input)).toEqual(['query { user { name } }']) + }) + + it('should handle multiple graphql calls in the same text', () => { + const input = ` + graphql("query1 { field1 }"); + graphql('query2 { field2 }'); + graphql(\`query3 { field3 }\`); + ` + expect(extractGraphQLStrings(input)).toEqual([ + 'query1 { field1 }', + 'query2 { field2 }', + 'query3 { field3 }', + ]) + }) + + it('should handle escaped quotes in double-quoted strings', () => { + const input = 'graphql("query { user(name: \\"John\\") { id } }")' + expect(extractGraphQLStrings(input)).toEqual(['query { user(name: "John") { id } }']) + }) + + it('should handle escaped quotes in single-quoted strings', () => { + const input = "graphql('query { user(name: \\'John\\') { id } }')" + expect(extractGraphQLStrings(input)).toEqual(["query { user(name: 'John') { id } }"]) + }) + + it('should handle escaped backticks in backtick strings', () => { + const input = 'graphql(`query { field(name: \\`test\\`) { id } }`)' + expect(extractGraphQLStrings(input)).toEqual(['query { field(name: `test`) { id } }']) + }) + + it('should handle nested parentheses', () => { + const input = 'graphql(someFunction("param"), "query { field }")' + expect(extractGraphQLStrings(input)).toEqual(['param', 'query { field }']) + }) + + it('should handle conditional expressions', () => { + const input = 'graphql(isDev ? "query1" : "query2")' + expect(extractGraphQLStrings(input)).toEqual(['query1', 'query2']) + }) + + it('should handle multiline queries', () => { + const input = ` + graphql(\` + query UserQuery { + user(id: "123") { + name + email + posts { + title + } + } + } + \`) + ` + expect(extractGraphQLStrings(input)).toEqual([ + `query UserQuery { + user(id: "123") { + name + email + posts { + title + } + } + }`, + ]) + }) + + it('should handle empty strings', () => { + const input = 'graphql("")' + expect(extractGraphQLStrings(input)).toEqual(['']) + }) + + it('should return empty array for text without graphql calls', () => { + const input = 'const query = "query { user { name } }";' + expect(extractGraphQLStrings(input)).toEqual([]) + }) + + it('should handle malformed graphql calls gracefully', () => { + const input = 'graphql("unclosed string' + expect(extractGraphQLStrings(input)).toEqual([]) + }) + + it('should handle multiple nested function calls', () => { + const input = 'graphql(getQuery("user", `nested`), formatQuery(\'test\'))' + expect(extractGraphQLStrings(input)).toEqual(['user', 'nested', 'test']) + }) + + it('should handle string concatenation', () => { + const input = 'graphql("query " + "fragment" + `template`)' + expect(extractGraphQLStrings(input)).toEqual(['query', 'fragment', 'template']) + }) +}) + +describe('extractGraphQLDocumentName', () => { + it('should extract query names', () => { + const cases = [ + { + input: 'query UserProfile { user { name } }', + expected: 'UserProfile', + }, + { + input: ` + query GetUserDetails { + user(id: "123") { + name + email + } + } + `, + expected: 'GetUserDetails', + }, + { + input: 'query _private_123 { field }', + expected: '_private_123', + }, + ] + + cases.forEach(({ input, expected }) => { + expect(extractGraphQLDocumentName(input)).toBe(expected) + }) + }) + + it('should extract mutation names', () => { + const cases = [ + { + input: 'mutation UpdateUser { updateUser { success } }', + expected: 'UpdateUser', + }, + { + input: ` + mutation DeleteAccount { + deleteUser(id: "123") { + success + message + } + } + `, + expected: 'DeleteAccount', + }, + ] + + cases.forEach(({ input, expected }) => { + expect(extractGraphQLDocumentName(input)).toBe(expected) + }) + }) + + it('should extract subscription names', () => { + const cases = [ + { + input: 'subscription UserEvents { userEvents { type } }', + expected: 'UserEvents', + }, + { + input: ` + subscription MessageNotifications { + messages { + id + content + } + } + `, + expected: 'MessageNotifications', + }, + ] + + cases.forEach(({ input, expected }) => { + expect(extractGraphQLDocumentName(input)).toBe(expected) + }) + }) + + it('should extract fragment names', () => { + const cases = [ + { + input: 'fragment UserFields on User { id name }', + expected: 'UserFields', + }, + { + input: ` + fragment PostDetails on Post { + title + content + author { + name + } + } + `, + expected: 'PostDetails', + }, + ] + + cases.forEach(({ input, expected }) => { + expect(extractGraphQLDocumentName(input)).toBe(expected) + }) + }) + + it('should handle comments correctly', () => { + const input = ` + # This is a comment + query UserProfile { # inline comment + user { # another comment + name # field comment + } + } + ` + expect(extractGraphQLDocumentName(input)).toBe('UserProfile') + }) + + it('should handle whitespace variants', () => { + const cases = [ + { + input: 'query UserProfile{user{name}}', + expected: 'UserProfile', + }, + { + input: `query + UserProfile + {user{name}}`, + expected: 'UserProfile', + }, + { + input: 'fragment UserFields on User{id}', + expected: 'UserFields', + }, + ] + + cases.forEach(({ input, expected }) => { + expect(extractGraphQLDocumentName(input)).toBe(expected) + }) + }) + + it('should return null for unnamed operations', () => { + const cases = [ + 'query { user { name } }', + 'mutation { updateUser { success } }', + 'subscription { userEvents { type } }', + '{ user { name } }', // shorthand query syntax + ] + + cases.forEach((input) => { + expect(extractGraphQLDocumentName(input)).toBe(null) + }) + }) + + it('should handle invalid or malformed input gracefully', () => { + const cases = [ + '', + ' ', + 'not a graphql document', + 'query {', // unclosed + 'fragment UserFields on', // incomplete + 'query 123InvalidName { field }', // invalid name + ] + + cases.forEach((input) => { + expect(extractGraphQLDocumentName(input)).toBe(null) + }) + }) +}) diff --git a/packages/houdini/src/vite/documents.ts b/packages/houdini/src/vite/documents.ts new file mode 100644 index 000000000..5b229c0b6 --- /dev/null +++ b/packages/houdini/src/vite/documents.ts @@ -0,0 +1,137 @@ +/** + * Check if a file has changed graphql documents + * @param source the source code of the file + * @param previousDocuments previous documents + * @returns [documents changed?, new documents object] + */ +export function graphQLDocumentsChanged( + source: string, + previousDocuments: Record | undefined +): [boolean, Record] { + const newDocuments = extractDocuments(source) + if (previousDocuments === undefined) return [true, newDocuments] + const newNames = Object.keys(newDocuments) + const oldNames = Object.keys(previousDocuments) + + if (newNames.length !== oldNames.length) return [true, newDocuments] + + return [ + !newNames.every( + (key) => key in previousDocuments && previousDocuments[key] === newDocuments[key] + ), + newDocuments, + ] +} + +/** + * Extract graphql documents from graphql(`...`) calls in the file + * does not attempt to do ast-level parsing, so that Svelte components, JSX and whatever else is supported, and the function stays fast. + * @param source the source code + * @returns an object mapping document names to their content + */ +export function extractDocuments(source: string): Record { + let extracted: Record = {} + for (const document of extractGraphQLStrings(source)) { + const name = extractGraphQLDocumentName(document) + if (!name) throw new Error('❌ All GraphQL documents must be named for Houdini to work') + extracted[name] = document + } + return extracted +} + +/** + * Extracts the name of a GraphQL document (operation or fragment) + * @param documentString The GraphQL document string + * @returns The name of the document or null if no name is found + * @throws Error if the document type cannot be determined + */ +export function extractGraphQLDocumentName(documentString: string): string | null { + // Remove comments and normalize whitespace + const normalized = documentString + .replace(/#.*$/gm, '') // Remove line comments + .replace(/\s+/g, ' ') // Normalize whitespace + .trim() + + // Match patterns for different document types + const patterns = { + query: /(?:query|mutation|subscription)\s+([_A-Za-z][_0-9A-Za-z]*)/, + fragment: /fragment\s+([_A-Za-z][_0-9A-Za-z]*)\s+on\s+/, + } + + // Try to match operation definition + const operationMatch = normalized.match(patterns.query) + if (operationMatch) { + return operationMatch[1] + } + + // Try to match fragment definition + const fragmentMatch = normalized.match(patterns.fragment) + if (fragmentMatch) { + return fragmentMatch[1] + } + + return null +} + +/** + * Extracts strings from graphql() function calls in text + * @param text The source text to extract GraphQL strings from + * @returns Array of extracted GraphQL strings + */ +export function extractGraphQLStrings(text: string): string[] { + const results: string[] = [] + let currentIndex = 0 + + // eslint-disable-next-line no-constant-condition + while (true) { + // Find the start of a graphql function call + const callStart = text.indexOf('graphql(', currentIndex) + if (callStart === -1) break + + // Move past 'graphql(' + let pos = callStart + 'graphql('.length + let openParens = 1 // Count of nested parentheses + let stringStart = -1 + let isEscaping = false + let currentString = '' + + // Parse until we find the closing parenthesis of the graphql call + while (pos < text.length && openParens > 0) { + const char = text[pos] + + if (stringStart === -1) { + // Not inside a string yet + if (char === '`' || char === '"' || char === "'") { + stringStart = pos + currentString = '' + } else if (char === '(') { + openParens++ + } else if (char === ')') { + openParens-- + } + } else { + // Inside a string + const stringChar = text[stringStart] + + if (isEscaping) { + currentString += char + isEscaping = false + } else if (char === '\\') { + isEscaping = true + } else if (char === stringChar) { + // String ended + results.push(currentString) + stringStart = -1 + } else { + currentString += char + } + } + + pos++ + } + + currentIndex = pos + } + + return results.map((result) => result.trim()) +} diff --git a/packages/houdini/src/vite/index.ts b/packages/houdini/src/vite/index.ts index f0938f59c..65d727a1e 100644 --- a/packages/houdini/src/vite/index.ts +++ b/packages/houdini/src/vite/index.ts @@ -1,11 +1,12 @@ -import type * as graphql from 'graphql' import minimatch from 'minimatch' -import type { Plugin } from 'vite' +import { readFile } from 'node:fs/promises' +import type { Plugin, ViteDevServer } from 'vite' import { watchAndRun } from 'vite-plugin-watch-and-run' import generate from '../codegen' import type { PluginConfig } from '../lib' -import { getConfig, formatErrors, path } from '../lib' +import { getConfig, formatErrors, path, loadLocalSchema } from '../lib' +import { graphQLDocumentsChanged } from './documents' import houdini_vite from './houdini' import { watch_local_schema, watch_remote_schema } from './schema' @@ -14,68 +15,127 @@ export * from './imports' export * from './schema' export * from './houdini' -export default function (opts?: PluginConfig): (Plugin | null)[] { +export default function (opts?: PluginConfig): Plugin[] { // we need some way for the graphql tag to detect that we are running on the server // so we don't get an error when importing. process.env.HOUDINI_PLUGIN = 'true' + // default autoCodeGen is watch + opts = { ...opts, autoCodeGen: opts?.autoCodeGen ?? 'watch' } + // a container of a list const watchSchemaListref = { list: [] as string[] } - return [ + // file contents to diff when autoCodeGen == smart + // maps file paths to extracted graphql documents: { document name: content } + const extractedDocuments: Record> = {} + + const plugins = [ houdini_vite(opts), watch_remote_schema(opts), watch_local_schema(watchSchemaListref), - watchAndRun([ - { - name: 'Houdini', - logs: [], - async watchFile(filepath: string) { - // load the config file - const config = await getConfig(opts) - - // we need to watch some specific files - if (config.localSchema) { - const toWatch = watchSchemaListref.list - if (toWatch.includes(filepath)) { - // if it's a schema change, let's reload the config - await getConfig({ ...opts, forceReload: true }) - return true - } - } else { - const schemaPath = path.join( - path.dirname(config.filepath), - config.schemaPath! - ) - if (minimatch(filepath, schemaPath)) { - // if it's a schema change, let's reload the config - await getConfig({ ...opts, forceReload: true }) - return true - } - } - - return config.includeFile(filepath, { root: process.cwd() }) - }, - async run(server) { - // load the config file - const config = await getConfig(opts) - if (config.localSchema) { - config.schema = (await server.ssrLoadModule(config.localSchemaPath)) - .default as graphql.GraphQLSchema - // reload the schema - // config.schema = await loadLocalSchema(config) - } - - // make sure we behave as if we're generating from inside the plugin (changes logging behavior) - config.pluginMode = true - - // generate the runtime - await generate(config) - }, - delay: 100, - watchKind: ['add', 'change', 'unlink'], - formatErrors, - }, - ]) as Plugin, ] + + const codegen = async (_server: ViteDevServer | undefined, absolutePath: string | null) => { + // load the config file + const config = await getConfig(opts) + if (config.localSchema) { + // reload the schema + config.schema = await loadLocalSchema(config) + } + + // make sure we behave as if we're generating from inside the plugin (changes logging behavior) + config.pluginMode = true + + if (opts?.autoCodeGen === 'smart' && absolutePath) { + const isGraphQLFile = /\.g(raph)?ql$/.test(absolutePath) + const fileContents = await readFile(absolutePath).then((buf) => buf.toString('utf8')) + if (fileContents && isGraphQLFile) { + // For graphql files, the extractedDocuments entry contains a single graphql "document" (which does not have to be an actual document) named '_' which is the entire file contents + // This allows us to only reload .gql files when their content actually changes. Writes to .gql files could not actually change their content, especially when the write was triggered by a write to the schema.graphql from the api server development workflow just because resolver implementation code was tweaked, but actual schema content was left unchanged. + const previousContent = extractedDocuments[absolutePath]?._.trim() ?? '' + // If the file is empty, don't assume anything changed + if (fileContents !== '' && previousContent !== fileContents) { + extractedDocuments[absolutePath] = { _: fileContents } + } else { + return + } + } else if (fileContents) { + const previousDocuments = extractedDocuments[absolutePath] + if (!previousDocuments) { + // To prevent full-page reloads every time we change something in a new document, just don't reload. + // Second change of the same document will trigger a reload + // It's better that way since most non-.gql files are modified for non-gql reasons most of the time + // This logic is reversed for .gql documents: reload on first change encountered since writes to graphql files are much more likely to cause a change in the graphql document + const [_, documents] = graphQLDocumentsChanged(fileContents, {}) + extractedDocuments[absolutePath] = documents + return + } + + const [documentsChanged, documents] = graphQLDocumentsChanged( + fileContents, + previousDocuments + ) + + if (documentsChanged) { + extractedDocuments[absolutePath] = documents + } else { + return + } + } + } + + // generate the runtime + await generate(config) + } + + switch (opts.autoCodeGen) { + case 'startup': + void codegen(undefined, null) + break + + case 'watch': + case 'smart': + plugins.push( + // @ts-ignore TODO + watchAndRun([ + { + name: 'Houdini', + async watchFile(filepath: string) { + // load the config file + const config = await getConfig(opts) + + // we need to watch some specific files + if (config.localSchema) { + const toWatch = watchSchemaListref.list + if (toWatch.includes(filepath)) { + // if it's a schema change, let's reload the config + await getConfig({ ...opts, forceReload: true }) + return true + } + } else { + const schemaPath = path.join( + path.dirname(config.filepath), + config.schemaPath! + ) + if (minimatch(filepath, schemaPath)) { + // if it's a schema change, let's reload the config + await getConfig({ ...opts, forceReload: true }) + return true + } + } + + return config.includeFile(filepath, { root: process.cwd() }) + }, + run: codegen, + delay: 100, + watchKind: ['add', 'change', 'unlink'], + formatErrors, + }, + ]) + ) + break + } + + return plugins }