Skip to content

Commit fd35f0d

Browse files
committed
Call generateExtensionTypes after building extensions when filters are updated
1 parent 9dd0147 commit fd35f0d

File tree

3 files changed

+155
-0
lines changed

3 files changed

+155
-0
lines changed

.changeset/twelve-memes-knock.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/app': minor
3+
---
4+
5+
Enable types to be re-generated when extensions are rebuilt during dev

packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,150 @@ describe('app-event-watcher', () => {
332332
)
333333
})
334334

335+
describe('generateExtensionTypes', () => {
336+
test('is called after extensions are rebuilt on file changes', async () => {
337+
await inTemporaryDirectory(async (tmpDir) => {
338+
const fileWatchEvent: WatcherEvent = {
339+
type: 'file_updated',
340+
path: '/extensions/ui_extension_1/src/file.js',
341+
extensionPath: '/extensions/ui_extension_1',
342+
startTime: [0, 0],
343+
}
344+
345+
// Given
346+
const mockedApp = testAppLinked({allExtensions: [extension1]})
347+
const generateTypesSpy = vi.spyOn(mockedApp, 'generateExtensionTypes')
348+
vi.mocked(loadApp).mockResolvedValue(mockedApp)
349+
350+
const buildOutputPath = joinPath(tmpDir, '.shopify', 'bundle')
351+
const app = testAppLinked({
352+
allExtensions: [extension1],
353+
configuration: {scopes: '', extension_directories: [], path: 'shopify.app.custom.toml'},
354+
})
355+
356+
const mockManager = new MockESBuildContextManager()
357+
const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent])
358+
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher)
359+
360+
// When
361+
await watcher.start({stdout, stderr, signal: abortController.signal})
362+
await flushPromises()
363+
364+
// Wait for event processing
365+
await new Promise((resolve) => setTimeout(resolve, 100))
366+
367+
// Then
368+
expect(generateTypesSpy).toHaveBeenCalled()
369+
})
370+
})
371+
372+
test('is not called again when extensions are created (already called during app reload)', async () => {
373+
await inTemporaryDirectory(async (tmpDir) => {
374+
const fileWatchEvent: WatcherEvent = {
375+
type: 'extension_folder_created',
376+
path: '/extensions/ui_extension_2',
377+
extensionPath: '/extensions/ui_extension_2',
378+
startTime: [0, 0],
379+
}
380+
381+
// Given
382+
const mockedApp = testAppLinked({allExtensions: [extension1, extension2]})
383+
const generateTypesSpy = vi.spyOn(mockedApp, 'generateExtensionTypes')
384+
vi.mocked(reloadApp).mockResolvedValue(mockedApp)
385+
386+
const buildOutputPath = joinPath(tmpDir, '.shopify', 'bundle')
387+
const app = testAppLinked({
388+
allExtensions: [extension1],
389+
configuration: {scopes: '', extension_directories: [], path: 'shopify.app.custom.toml'},
390+
})
391+
392+
const mockManager = new MockESBuildContextManager()
393+
const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent])
394+
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher)
395+
396+
// When
397+
await watcher.start({stdout, stderr, signal: abortController.signal})
398+
await flushPromises()
399+
400+
// Wait for event processing
401+
await new Promise((resolve) => setTimeout(resolve, 100))
402+
403+
// Then - not called in watcher because it was already called during reloadApp
404+
expect(generateTypesSpy).not.toHaveBeenCalled()
405+
})
406+
})
407+
408+
test('is not called again when app config is updated (already called during app reload)', async () => {
409+
await inTemporaryDirectory(async (tmpDir) => {
410+
const fileWatchEvent: WatcherEvent = {
411+
type: 'extensions_config_updated',
412+
path: 'shopify.app.custom.toml',
413+
extensionPath: '/',
414+
startTime: [0, 0],
415+
}
416+
417+
// Given
418+
const mockedApp = testAppLinked({allExtensions: [extension1, posExtensionUpdated]})
419+
const generateTypesSpy = vi.spyOn(mockedApp, 'generateExtensionTypes')
420+
vi.mocked(reloadApp).mockResolvedValue(mockedApp)
421+
422+
const buildOutputPath = joinPath(tmpDir, '.shopify', 'bundle')
423+
const app = testAppLinked({
424+
allExtensions: [extension1, posExtension],
425+
configuration: {scopes: '', extension_directories: [], path: 'shopify.app.custom.toml'},
426+
})
427+
428+
const mockManager = new MockESBuildContextManager()
429+
const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent])
430+
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher)
431+
432+
// When
433+
await watcher.start({stdout, stderr, signal: abortController.signal})
434+
await flushPromises()
435+
436+
// Wait for event processing
437+
await new Promise((resolve) => setTimeout(resolve, 100))
438+
439+
// Then - not called in watcher because it was already called during reloadApp
440+
expect(generateTypesSpy).not.toHaveBeenCalled()
441+
})
442+
})
443+
444+
test('is called when extensions are deleted to clean up types', async () => {
445+
await inTemporaryDirectory(async (tmpDir) => {
446+
const fileWatchEvent: WatcherEvent = {
447+
type: 'extension_folder_deleted',
448+
path: '/extensions/ui_extension_1',
449+
extensionPath: '/extensions/ui_extension_1',
450+
startTime: [0, 0],
451+
}
452+
453+
// Given
454+
const buildOutputPath = joinPath(tmpDir, '.shopify', 'bundle')
455+
const app = testAppLinked({
456+
allExtensions: [extension1, extension2],
457+
configuration: {scopes: '', extension_directories: [], path: 'shopify.app.custom.toml'},
458+
})
459+
const generateTypesSpy = vi.spyOn(app, 'generateExtensionTypes')
460+
461+
const mockManager = new MockESBuildContextManager()
462+
const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent])
463+
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher)
464+
465+
// When
466+
await watcher.start({stdout, stderr, signal: abortController.signal})
467+
await flushPromises()
468+
469+
// Wait for event processing
470+
await new Promise((resolve) => setTimeout(resolve, 100))
471+
472+
// Then - generateExtensionTypes should still be called when extensions are deleted
473+
// to clean up type definitions for the removed extension
474+
expect(generateTypesSpy).toHaveBeenCalled()
475+
})
476+
})
477+
})
478+
335479
describe('app-event-watcher build extension errors', () => {
336480
test('esbuild errors are logged with a custom format', async () => {
337481
await inTemporaryDirectory(async (tmpDir) => {

packages/app/src/cli/services/dev/app-events/app-event-watcher.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ export class AppEventWatcher extends EventEmitter {
163163
// Build the created/updated extensions and update the extension events with the build result
164164
await this.buildExtensions(buildableEvents)
165165

166+
// Generate the extension types after building the extensions so new imports are included
167+
// Skip if the app was reloaded, as generateExtensionTypes was already called during reload
168+
if (!appEvent.appWasReloaded) {
169+
await this.app.generateExtensionTypes()
170+
}
171+
166172
// Find deleted extensions and delete their previous build output
167173
await this.deleteExtensionsBuildOutput(appEvent)
168174
this.emit('all', appEvent)

0 commit comments

Comments
 (0)