From 61d1a5d9dd936cdd8fa78f302f809f8a7f1bd1df Mon Sep 17 00:00:00 2001 From: Trish Ta Date: Thu, 27 Nov 2025 09:48:03 -0500 Subject: [PATCH] Call generateExtensionTypes after building extensions when filters are updated --- .changeset/twelve-memes-knock.md | 5 + .../dev/app-events/app-event-watcher.test.ts | 141 ++++++++++++++++++ .../dev/app-events/app-event-watcher.ts | 6 + 3 files changed, 152 insertions(+) create mode 100644 .changeset/twelve-memes-knock.md diff --git a/.changeset/twelve-memes-knock.md b/.changeset/twelve-memes-knock.md new file mode 100644 index 00000000000..5d36cc47d1a --- /dev/null +++ b/.changeset/twelve-memes-knock.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Enable types to be re-generated when extensions are rebuilt during dev diff --git a/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts b/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts index 4ec5bc90d6e..7de43c69b3f 100644 --- a/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts +++ b/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts @@ -332,6 +332,147 @@ describe('app-event-watcher', () => { ) }) + describe('generateExtensionTypes', () => { + test('is called after extensions are rebuilt on file changes', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const fileWatchEvent: WatcherEvent = { + type: 'file_updated', + path: '/extensions/ui_extension_1/src/file.js', + extensionPath: '/extensions/ui_extension_1', + startTime: [0, 0], + } + + // Given + const buildOutputPath = joinPath(tmpDir, '.shopify', 'bundle') + const app = testAppLinked({ + allExtensions: [extension1], + configuration: {scopes: '', extension_directories: [], path: 'shopify.app.custom.toml'}, + }) + const generateTypesSpy = vi.spyOn(app, 'generateExtensionTypes') + + const mockManager = new MockESBuildContextManager() + const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent]) + const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher) + + // When + await watcher.start({stdout, stderr, signal: abortController.signal}) + await flushPromises() + + // Wait for event processing + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Then + expect(generateTypesSpy).toHaveBeenCalled() + }) + }) + + test('is not called again when extensions are created (already called during app reload)', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const fileWatchEvent: WatcherEvent = { + type: 'extension_folder_created', + path: '/extensions/ui_extension_2', + extensionPath: '/extensions/ui_extension_2', + startTime: [0, 0], + } + + // Given + const mockedApp = testAppLinked({allExtensions: [extension1, extension2]}) + const generateTypesSpy = vi.spyOn(mockedApp, 'generateExtensionTypes') + vi.mocked(reloadApp).mockResolvedValue(mockedApp) + + const buildOutputPath = joinPath(tmpDir, '.shopify', 'bundle') + const app = testAppLinked({ + allExtensions: [extension1], + configuration: {scopes: '', extension_directories: [], path: 'shopify.app.custom.toml'}, + }) + + const mockManager = new MockESBuildContextManager() + const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent]) + const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher) + + // When + await watcher.start({stdout, stderr, signal: abortController.signal}) + await flushPromises() + + // Wait for event processing + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Then - not called in watcher because it was already called during reloadApp + expect(generateTypesSpy).not.toHaveBeenCalled() + }) + }) + + test('is not called again when app config is updated (already called during app reload)', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const fileWatchEvent: WatcherEvent = { + type: 'extensions_config_updated', + path: 'shopify.app.custom.toml', + extensionPath: '/', + startTime: [0, 0], + } + + // Given + const mockedApp = testAppLinked({allExtensions: [extension1, posExtensionUpdated]}) + const generateTypesSpy = vi.spyOn(mockedApp, 'generateExtensionTypes') + vi.mocked(reloadApp).mockResolvedValue(mockedApp) + + const buildOutputPath = joinPath(tmpDir, '.shopify', 'bundle') + const app = testAppLinked({ + allExtensions: [extension1, posExtension], + configuration: {scopes: '', extension_directories: [], path: 'shopify.app.custom.toml'}, + }) + + const mockManager = new MockESBuildContextManager() + const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent]) + const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher) + + // When + await watcher.start({stdout, stderr, signal: abortController.signal}) + await flushPromises() + + // Wait for event processing + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Then - not called in watcher because it was already called during reloadApp + expect(generateTypesSpy).not.toHaveBeenCalled() + }) + }) + + test('is called when extensions are deleted to clean up types', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const fileWatchEvent: WatcherEvent = { + type: 'extension_folder_deleted', + path: '/extensions/ui_extension_1', + extensionPath: '/extensions/ui_extension_1', + startTime: [0, 0], + } + + // Given + const buildOutputPath = joinPath(tmpDir, '.shopify', 'bundle') + const app = testAppLinked({ + allExtensions: [extension1, extension2], + configuration: {scopes: '', extension_directories: [], path: 'shopify.app.custom.toml'}, + }) + const generateTypesSpy = vi.spyOn(app, 'generateExtensionTypes') + + const mockManager = new MockESBuildContextManager() + const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent]) + const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher) + + // When + await watcher.start({stdout, stderr, signal: abortController.signal}) + await flushPromises() + + // Wait for event processing + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Then - generateExtensionTypes should still be called when extensions are deleted + // to clean up type definitions for the removed extension + expect(generateTypesSpy).toHaveBeenCalled() + }) + }) + }) + describe('app-event-watcher build extension errors', () => { test('esbuild errors are logged with a custom format', async () => { await inTemporaryDirectory(async (tmpDir) => { diff --git a/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts b/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts index 07fc9d93568..5a760de6332 100644 --- a/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts +++ b/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts @@ -163,6 +163,12 @@ export class AppEventWatcher extends EventEmitter { // Build the created/updated extensions and update the extension events with the build result await this.buildExtensions(buildableEvents) + // Generate the extension types after building the extensions so new imports are included + // Skip if the app was reloaded, as generateExtensionTypes was already called during reload + if (!appEvent.appWasReloaded) { + await this.app.generateExtensionTypes() + } + // Find deleted extensions and delete their previous build output await this.deleteExtensionsBuildOutput(appEvent) this.emit('all', appEvent)