From b63814ad8ccb1727e75384f605e52aab8f245018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 8 Apr 2026 18:18:53 +0200 Subject: [PATCH 1/3] Extract custom watch paths to specifications Co-authored-by: Claude Code --- .../models/extensions/extension-instance.ts | 44 +++++++++++-------- .../cli/models/extensions/specification.ts | 15 +++++++ .../models/extensions/specifications/admin.ts | 27 +++++++++++- .../extensions/specifications/function.ts | 12 +++++ .../services/dev/app-events/file-watcher.ts | 9 ---- 5 files changed, 77 insertions(+), 30 deletions(-) diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 318617ab2b7..6da9a76ebf7 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -1,6 +1,6 @@ import {BaseConfigType, MAX_EXTENSION_HANDLE_LENGTH, MAX_UID_LENGTH} from './schemas.js' import {FunctionConfigType} from './specifications/function.js' -import {ExtensionFeature, ExtensionSpecification} from './specification.js' +import {DevSessionWatchConfig, ExtensionFeature, ExtensionSpecification} from './specification.js' import {SingleWebhookSubscriptionType} from './specifications/app_config_webhook_schemas/webhooks_schema.js' import {ExtensionBuildOptions, bundleFunctionExtension} from '../../services/build/extension.js' import {bundleThemeExtension} from '../../services/extensions/bundle.js' @@ -277,20 +277,15 @@ export class ExtensionInstance joinPath(this.directory, path)) - - watchPaths.push(joinPath(this.directory, 'locales', '**.json')) - watchPaths.push(joinPath(this.directory, '**', '!(.)*.graphql')) - watchPaths.push(joinPath(this.directory, '**.toml')) + // Custom watch configuration for dev sessions + // Return undefined to watch everything (default for 'extension' experience) + // Return a config with empty paths to watch nothing (default for 'configuration' experience) + get devSessionWatchConfig(): DevSessionWatchConfig | undefined { + if (this.specification.devSessionWatchConfig) { + return this.specification.devSessionWatchConfig(this) + } - return watchPaths + return this.specification.experience === 'configuration' ? {paths: []} : undefined } async watchConfigurationPaths() { @@ -436,20 +431,31 @@ export class ExtensionInstance globSync(pattern, { cwd: this.directory, absolute: true, followSymbolicLinks: false, - ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/*.swp', '**/generated/**'], + ignore, }), ) watchedFiles.push(...files.flat()) - // Add imported files from outside the extension directory unless custom watch paths are defined - if (!this.devSessionCustomWatchPaths) { + // Add imported files from outside the extension directory unless custom watch config is defined + if (!watchConfig) { const importedFiles = this.scanImports() watchedFiles.push(...importedFiles) } diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 2f7b1bea33c..099d554606e 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -136,6 +136,20 @@ export interface ExtensionSpecification Promise + + /** + * Custom watch configuration for dev sessions. + * Return a DevSessionWatchConfig with paths to watch and optionally paths to ignore, + * or undefined to watch all files in the extension directory. + */ + devSessionWatchConfig?: (extension: ExtensionInstance) => DevSessionWatchConfig | undefined +} + +export interface DevSessionWatchConfig { + /** Absolute paths or globs to watch */ + paths: string[] + /** Additional glob patterns to ignore (on top of the default ignore list) */ + ignore?: string[] } /** @@ -294,6 +308,7 @@ export function createContractBasedModuleSpecification, ) { return createExtensionSpecification({ diff --git a/packages/app/src/cli/models/extensions/specifications/admin.ts b/packages/app/src/cli/models/extensions/specifications/admin.ts index 0aea2ee661a..2ef549c11a6 100644 --- a/packages/app/src/cli/models/extensions/specifications/admin.ts +++ b/packages/app/src/cli/models/extensions/specifications/admin.ts @@ -1,8 +1,31 @@ -import {createContractBasedModuleSpecification} from '../specification.js' +import {createExtensionSpecification} from '../specification.js' +import {BaseSchemaWithoutHandle} from '../schemas.js' +import {zod} from '@shopify/cli-kit/node/schema' +import {joinPath} from '@shopify/cli-kit/node/path' -const adminSpecificationSpec = createContractBasedModuleSpecification({ +const AdminSchema = BaseSchemaWithoutHandle.extend({ + admin: zod + .object({ + static_root: zod.string().optional(), + }) + .optional(), +}) + +const adminSpecificationSpec = createExtensionSpecification({ identifier: 'admin', uidStrategy: 'single', + experience: 'configuration', + schema: AdminSchema, + deployConfig: async (config, _) => { + return {admin: config.admin} + }, + devSessionWatchConfig: (extension) => { + const staticRoot = extension.configuration.admin?.static_root + if (!staticRoot) return {paths: []} + + const path = joinPath(extension.directory, staticRoot, '**/*') + return {paths: [path], ignore: []} + }, transformRemoteToLocal: (remoteContent) => { return { admin: { diff --git a/packages/app/src/cli/models/extensions/specifications/function.ts b/packages/app/src/cli/models/extensions/specifications/function.ts index 9f343ac6611..d40d83e523b 100644 --- a/packages/app/src/cli/models/extensions/specifications/function.ts +++ b/packages/app/src/cli/models/extensions/specifications/function.ts @@ -90,6 +90,18 @@ const functionSpec = createExtensionSpecification({ appModuleFeatures: (_) => ['function'], buildConfig: {mode: 'function'}, getOutputRelativePath: (_extension: ExtensionInstance) => joinPath('dist', 'index.wasm'), + devSessionWatchConfig: (extension: ExtensionInstance) => { + const config = extension.configuration + if (!config.build || !config.build.watch) return undefined + + const paths = [config.build.watch].flat().map((path) => joinPath(extension.directory, path)) + + paths.push(joinPath(extension.directory, 'locales', '**.json')) + paths.push(joinPath(extension.directory, '**', '!(.)*.graphql')) + paths.push(joinPath(extension.directory, '**.toml')) + + return {paths} + }, clientSteps: [ { lifecycle: 'deploy', diff --git a/packages/app/src/cli/services/dev/app-events/file-watcher.ts b/packages/app/src/cli/services/dev/app-events/file-watcher.ts index e5995b140c7..2e11eadc513 100644 --- a/packages/app/src/cli/services/dev/app-events/file-watcher.ts +++ b/packages/app/src/cli/services/dev/app-events/file-watcher.ts @@ -118,15 +118,6 @@ export class FileWatcher { // Create new watcher const {default: chokidar} = await import('chokidar') this.watcher = chokidar.watch(watchPaths, { - ignored: [ - '**/node_modules/**', - '**/.git/**', - '**/*.test.*', - '**/dist/**', - '**/*.swp', - '**/generated/**', - '**/.gitignore', - ], persistent: true, ignoreInitial: true, }) From f25fe40babe58931e36a5c532e77e2beb5078df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 8 Apr 2026 18:48:56 +0200 Subject: [PATCH 2/3] Fix loader tests for admin config extension and pass through devSessionWatchConfig Co-authored-by: Claude Code --- packages/app/src/cli/models/app/loader.test.ts | 10 ++++++++-- .../app/src/cli/models/extensions/specification.ts | 1 + .../cli/services/dev/app-events/file-watcher.test.ts | 9 --------- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index 4f35ca534b0..d10430b05dc 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -2070,9 +2070,12 @@ describe('load', () => { const app = await loadTestingApp() // Then - expect(app.allExtensions).toHaveLength(6) + expect(app.allExtensions).toHaveLength(7) const extensionsConfig = app.allExtensions.map((ext) => ext.configuration) expect(extensionsConfig).toEqual([ + expect.objectContaining({ + name: 'for-testing', + }), expect.objectContaining({ name: 'for-testing', }), @@ -2128,9 +2131,12 @@ describe('load', () => { const app = await loadTestingApp({remoteFlags: []}) // Then - expect(app.allExtensions).toHaveLength(7) + expect(app.allExtensions).toHaveLength(8) const extensionsConfig = app.allExtensions.map((ext) => ext.configuration) expect(extensionsConfig).toEqual([ + { + name: 'for-testing-webhooks', + }, { name: 'for-testing-webhooks', }, diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 099d554606e..01b3df8e75b 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -320,6 +320,7 @@ export function createContractBasedModuleSpecification { let parsedConfig = configWithoutFirstClassFields(config) if (spec.appModuleFeatures().includes('localization')) { diff --git a/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts b/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts index b69565d39bb..ebb1c5d2a87 100644 --- a/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts +++ b/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts @@ -274,15 +274,6 @@ describe('file-watcher events', () => { // Then expect(watchSpy).toHaveBeenCalledWith([joinPath(dir, '/shopify.app.toml'), joinPath(dir, '/extensions')], { - ignored: [ - '**/node_modules/**', - '**/.git/**', - '**/*.test.*', - '**/dist/**', - '**/*.swp', - '**/generated/**', - '**/.gitignore', - ], ignoreInitial: true, persistent: true, }) From 06b34f4266d6b71fb1e8c829c01309a0e001ca75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 9 Apr 2026 15:38:41 +0200 Subject: [PATCH 3/3] Add tests for devSessionWatchConfig and watchedFiles behavior Co-authored-by: Claude Code --- .../app/src/cli/models/app/loader.test.ts | 10 +-- .../extensions/extension-instance.test.ts | 82 +++++++++++++++++++ .../cli/models/extensions/specification.ts | 2 +- .../models/extensions/specifications/admin.ts | 10 ++- .../dev/app-events/file-watcher.test.ts | 1 + .../services/dev/app-events/file-watcher.ts | 1 + 6 files changed, 93 insertions(+), 13 deletions(-) diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index d10430b05dc..4f35ca534b0 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -2070,12 +2070,9 @@ describe('load', () => { const app = await loadTestingApp() // Then - expect(app.allExtensions).toHaveLength(7) + expect(app.allExtensions).toHaveLength(6) const extensionsConfig = app.allExtensions.map((ext) => ext.configuration) expect(extensionsConfig).toEqual([ - expect.objectContaining({ - name: 'for-testing', - }), expect.objectContaining({ name: 'for-testing', }), @@ -2131,12 +2128,9 @@ describe('load', () => { const app = await loadTestingApp({remoteFlags: []}) // Then - expect(app.allExtensions).toHaveLength(8) + expect(app.allExtensions).toHaveLength(7) const extensionsConfig = app.allExtensions.map((ext) => ext.configuration) expect(extensionsConfig).toEqual([ - { - name: 'for-testing-webhooks', - }, { name: 'for-testing-webhooks', }, diff --git a/packages/app/src/cli/models/extensions/extension-instance.test.ts b/packages/app/src/cli/models/extensions/extension-instance.test.ts index c765e6e6f6c..c3aa272a3de 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.test.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.test.ts @@ -570,6 +570,36 @@ describe('draftMessages', async () => { }) }) +describe('devSessionWatchConfig', () => { + test('returns undefined for extension experience (watch everything)', async () => { + const extensionInstance = await testUIExtension({type: 'ui_extension'}) + expect(extensionInstance.devSessionWatchConfig).toBeUndefined() + }) + + test('returns empty paths for configuration experience (watch nothing)', async () => { + const extensionInstance = await testAppConfigExtensions() + expect(extensionInstance.devSessionWatchConfig).toEqual({paths: []}) + }) + + test('delegates to specification devSessionWatchConfig when defined', async () => { + const config = functionConfiguration() + config.build = { + watch: 'src/**/*.rs', + wasm_opt: true, + } + const extensionInstance = await testFunctionExtension({config, dir: '/tmp/my-function'}) + const watchConfig = extensionInstance.devSessionWatchConfig + expect(watchConfig).toBeDefined() + expect(watchConfig!.paths).toContain(joinPath('/tmp/my-function', 'src/**/*.rs')) + }) + + test('returns undefined for function extension without build.watch', async () => { + const config = functionConfiguration() + const extensionInstance = await testFunctionExtension({config}) + expect(extensionInstance.devSessionWatchConfig).toBeUndefined() + }) +}) + describe('watchedFiles', async () => { test('returns files based on devSessionWatchPaths when defined', async () => { await inTemporaryDirectory(async (tmpDir) => { @@ -607,6 +637,58 @@ describe('watchedFiles', async () => { }) }) + test('respects custom ignore paths from devSessionWatchConfig', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given - create an extension with a spec that defines custom ignore paths + const config = functionConfiguration() + config.build = { + watch: '**/*', + wasm_opt: true, + } + const extensionInstance = await testFunctionExtension({ + config, + dir: tmpDir, + }) + + // Override devSessionWatchConfig to include ignore paths + vi.spyOn(extensionInstance, 'devSessionWatchConfig', 'get').mockReturnValue({ + paths: [joinPath(tmpDir, '**/*')], + ignore: ['**/ignored-dir/**'], + }) + + // Create files - one in a normal dir, one in the ignored dir + const srcDir = joinPath(tmpDir, 'src') + const ignoredDir = joinPath(tmpDir, 'ignored-dir') + await mkdir(srcDir) + await mkdir(ignoredDir) + await writeFile(joinPath(srcDir, 'index.js'), 'code') + await writeFile(joinPath(ignoredDir, 'should-be-ignored.js'), 'ignored') + + // When + const watchedFiles = extensionInstance.watchedFiles() + + // Then + expect(watchedFiles).toContain(joinPath(srcDir, 'index.js')) + expect(watchedFiles).not.toContain(joinPath(ignoredDir, 'should-be-ignored.js')) + }) + }) + + test('returns empty watched files for configuration extensions', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionInstance = await testAppConfigExtensions(false, tmpDir) + + // Create files that should not be watched + await writeFile(joinPath(tmpDir, 'some-file.ts'), 'code') + + // When + const watchedFiles = extensionInstance.watchedFiles() + + // Then - configuration extensions default to empty paths, so no files watched + expect(watchedFiles).toHaveLength(0) + }) + }) + test('returns all files when devSessionWatchPaths is undefined', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 01b3df8e75b..94907693f10 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -148,7 +148,7 @@ export interface ExtensionSpecification & BaseConfigType + +const adminSpecificationSpec = createExtensionSpecification({ identifier: 'admin', uidStrategy: 'single', experience: 'configuration', - schema: AdminSchema, + schema: AdminSchema as ZodSchemaType, deployConfig: async (config, _) => { return {admin: config.admin} }, diff --git a/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts b/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts index ebb1c5d2a87..725604a59d1 100644 --- a/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts +++ b/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts @@ -274,6 +274,7 @@ describe('file-watcher events', () => { // Then expect(watchSpy).toHaveBeenCalledWith([joinPath(dir, '/shopify.app.toml'), joinPath(dir, '/extensions')], { + ignored: ['**/node_modules/**', '**/.git/**'], ignoreInitial: true, persistent: true, }) diff --git a/packages/app/src/cli/services/dev/app-events/file-watcher.ts b/packages/app/src/cli/services/dev/app-events/file-watcher.ts index 2e11eadc513..ca55158bae2 100644 --- a/packages/app/src/cli/services/dev/app-events/file-watcher.ts +++ b/packages/app/src/cli/services/dev/app-events/file-watcher.ts @@ -118,6 +118,7 @@ export class FileWatcher { // Create new watcher const {default: chokidar} = await import('chokidar') this.watcher = chokidar.watch(watchPaths, { + ignored: ['**/node_modules/**', '**/.git/**'], persistent: true, ignoreInitial: true, })