Skip to content

Commit beeeb1a

Browse files
authored
Merge pull request #7491 from Shopify/file-watcher-detect-new-files
Detect new files added during dev session in file watcher
2 parents 75a6c48 + 3f7b6f1 commit beeeb1a

4 files changed

Lines changed: 241 additions & 18 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/app': patch
3+
---
4+
5+
Detect files added during a running dev session

packages/app/src/cli/models/extensions/extension-instance.ts

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@ import {
2626
import {isTruthy} from '@shopify/cli-kit/node/context/utilities'
2727
import {uniq} from '@shopify/cli-kit/common/array'
2828

29+
/**
30+
* Default ignore patterns for files watched in an extension directory. Used when
31+
* the extension does not provide a custom devSessionWatchConfig.
32+
*/
33+
const DEFAULT_WATCH_IGNORE = [
34+
'**/node_modules/**',
35+
'**/.git/**',
36+
'**/*.test.*',
37+
'**/dist/**',
38+
'**/*.swp',
39+
'**/generated/**',
40+
'**/.gitignore',
41+
]
42+
2943
/**
3044
* Class that represents an instance of a local extension
3145
* Before creating this class we've validated that:
@@ -423,28 +437,28 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
423437
await this.specification.contributeToSharedTypeFile?.(this, typeDefinitionsByFile)
424438
}
425439

440+
/**
441+
* Returns the raw watch patterns (paths + ignore) for this extension, without
442+
* expanding globs against the file system. The file watcher uses this to decide
443+
* whether a path created at runtime should be tracked by this extension.
444+
*/
445+
watchPatterns(): {paths: string[]; ignore: string[]} {
446+
const watchConfig = this.devSessionWatchConfig
447+
return {
448+
paths: watchConfig?.paths ?? ['**/*'],
449+
ignore: watchConfig?.ignore ?? DEFAULT_WATCH_IGNORE,
450+
}
451+
}
452+
426453
/**
427454
* Returns all files that need to be watched for this extension
428455
* This includes files in the extension directory (respecting watch paths and gitignore)
429456
* as well as any imported files from outside the extension directory
430457
*/
431458
watchedFiles(): string[] {
432459
const watchedFiles: string[] = []
433-
434-
const defaultIgnore = [
435-
'**/node_modules/**',
436-
'**/.git/**',
437-
'**/*.test.*',
438-
'**/dist/**',
439-
'**/*.swp',
440-
'**/generated/**',
441-
'**/.gitignore',
442-
]
443-
const watchConfig = this.devSessionWatchConfig
444-
445-
const patterns = watchConfig?.paths ?? ['**/*']
446-
const ignore = watchConfig?.ignore ?? defaultIgnore
447-
const files = patterns.flatMap((pattern) =>
460+
const {paths, ignore} = this.watchPatterns()
461+
const files = paths.flatMap((pattern) =>
448462
globSync(pattern, {
449463
cwd: this.directory,
450464
absolute: true,
@@ -455,7 +469,7 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
455469
watchedFiles.push(...files.flat())
456470

457471
// Add imported files from outside the extension directory unless custom watch config is defined
458-
if (!watchConfig) {
472+
if (!this.devSessionWatchConfig) {
459473
const importedFiles = this.scanImports()
460474
watchedFiles.push(...importedFiles)
461475
}

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

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,153 @@ describe('file-watcher events', () => {
810810
})
811811
})
812812

813+
describe('runtime file discovery', () => {
814+
test('files added at runtime inside an existing extension trigger file_created', async () => {
815+
// Given: extension knows about index.js but NOT runtime-added.js
816+
mockExtensionWatchedFiles(extension1, ['/extensions/ui_extension_1/index.js'])
817+
mockExtensionWatchedFiles(extension1B, ['/extensions/ui_extension_1/index.js'])
818+
mockExtensionWatchedFiles(extension2, [])
819+
mockExtensionWatchedFiles(functionExtension, [])
820+
mockExtensionWatchedFiles(posExtension, [])
821+
mockExtensionWatchedFiles(appAccessExtension, [])
822+
823+
const testApp = {
824+
...defaultApp,
825+
allExtensions: defaultApp.allExtensions,
826+
nonConfigExtensions: defaultApp.allExtensions.filter((ext) => !ext.isAppConfigExtension),
827+
realExtensions: defaultApp.allExtensions,
828+
}
829+
830+
let eventHandler: any
831+
const mockWatcher = {
832+
on: vi.fn((event: string, listener: any) => {
833+
if (event === 'all') eventHandler = listener
834+
return mockWatcher
835+
}),
836+
close: vi.fn(() => Promise.resolve()),
837+
}
838+
vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any)
839+
vi.mocked(fileExistsSync).mockReturnValue(false)
840+
841+
const fileWatcher = new FileWatcher(testApp, outputOptions, 50)
842+
const onChange = vi.fn()
843+
fileWatcher.onChange(onChange)
844+
await fileWatcher.start()
845+
await flushPromises()
846+
847+
// When: a file the extension didn't pre-register is created on disk
848+
await eventHandler('add', '/extensions/ui_extension_1/runtime-added.js', undefined)
849+
await sleep(0.15)
850+
851+
// Then: it's attributed to the owning extensions and emitted
852+
await vi.waitFor(
853+
() => {
854+
const events = onChange.mock.calls.find((call) => call[0].length > 0)?.[0]
855+
if (!events) throw new Error('no events emitted')
856+
expect(events).toHaveLength(2)
857+
for (const event of events) {
858+
expect(event.type).toBe('file_created')
859+
expect(event.path).toBe('/extensions/ui_extension_1/runtime-added.js')
860+
expect(event.extensionPath).toBe('/extensions/ui_extension_1')
861+
}
862+
const handles = events.map((event: WatcherEvent) => event.extensionHandle).sort()
863+
expect(handles).toEqual(['h1', 'h2'])
864+
},
865+
{timeout: 1000, interval: 50},
866+
)
867+
})
868+
869+
test('files added at runtime outside any extension are ignored', async () => {
870+
mockExtensionWatchedFiles(extension1, [])
871+
mockExtensionWatchedFiles(extension1B, [])
872+
mockExtensionWatchedFiles(extension2, [])
873+
mockExtensionWatchedFiles(functionExtension, [])
874+
mockExtensionWatchedFiles(posExtension, [])
875+
mockExtensionWatchedFiles(appAccessExtension, [])
876+
877+
const testApp = {
878+
...defaultApp,
879+
allExtensions: defaultApp.allExtensions,
880+
nonConfigExtensions: defaultApp.allExtensions.filter((ext) => !ext.isAppConfigExtension),
881+
realExtensions: defaultApp.allExtensions,
882+
}
883+
884+
let eventHandler: any
885+
const mockWatcher = {
886+
on: vi.fn((event: string, listener: any) => {
887+
if (event === 'all') eventHandler = listener
888+
return mockWatcher
889+
}),
890+
close: vi.fn(() => Promise.resolve()),
891+
}
892+
vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any)
893+
vi.mocked(fileExistsSync).mockReturnValue(false)
894+
895+
const fileWatcher = new FileWatcher(testApp, outputOptions, 50)
896+
const onChange = vi.fn()
897+
fileWatcher.onChange(onChange)
898+
await fileWatcher.start()
899+
await flushPromises()
900+
901+
await eventHandler('add', '/some/random/path/file.js', undefined)
902+
await sleep(0.15)
903+
904+
const hasNonEmptyCall = onChange.mock.calls.some((call) => call[0].length > 0)
905+
expect(hasNonEmptyCall).toBe(false)
906+
})
907+
908+
test('subsequent change/unlink on a runtime-discovered file are not dropped', async () => {
909+
mockExtensionWatchedFiles(extension1, ['/extensions/ui_extension_1/index.js'])
910+
mockExtensionWatchedFiles(extension1B, ['/extensions/ui_extension_1/index.js'])
911+
mockExtensionWatchedFiles(extension2, [])
912+
mockExtensionWatchedFiles(functionExtension, [])
913+
mockExtensionWatchedFiles(posExtension, [])
914+
mockExtensionWatchedFiles(appAccessExtension, [])
915+
916+
const testApp = {
917+
...defaultApp,
918+
allExtensions: defaultApp.allExtensions,
919+
nonConfigExtensions: defaultApp.allExtensions.filter((ext) => !ext.isAppConfigExtension),
920+
realExtensions: defaultApp.allExtensions,
921+
}
922+
923+
let eventHandler: any
924+
const mockWatcher = {
925+
on: vi.fn((event: string, listener: any) => {
926+
if (event === 'all') eventHandler = listener
927+
return mockWatcher
928+
}),
929+
close: vi.fn(() => Promise.resolve()),
930+
}
931+
vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any)
932+
vi.mocked(fileExistsSync).mockReturnValue(false)
933+
934+
const fileWatcher = new FileWatcher(testApp, outputOptions, 50)
935+
const onChange = vi.fn()
936+
fileWatcher.onChange(onChange)
937+
await fileWatcher.start()
938+
await flushPromises()
939+
940+
// Discover the file via 'add'
941+
await eventHandler('add', '/extensions/ui_extension_1/runtime-added.js', undefined)
942+
await sleep(0.1)
943+
944+
// Now fire a 'change' on the same path; should produce a file_updated event
945+
onChange.mockClear()
946+
await eventHandler('change', '/extensions/ui_extension_1/runtime-added.js', undefined)
947+
await sleep(0.1)
948+
949+
await vi.waitFor(
950+
() => {
951+
const events = onChange.mock.calls.find((call) => call[0].length > 0)?.[0]
952+
if (!events) throw new Error('no change events emitted')
953+
expect(events.some((event: WatcherEvent) => event.type === 'file_updated')).toBe(true)
954+
},
955+
{timeout: 1000, interval: 50},
956+
)
957+
})
958+
})
959+
813960
describe('refreshWatchedFiles', () => {
814961
test('closes and recreates the watcher with updated paths', async () => {
815962
// Given

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

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable no-case-declarations */
22
import {AppLinkedInterface} from '../../../models/app/app.js'
3+
import {ExtensionInstance} from '../../../models/extensions/extension-instance.js'
34
import {configurationFileNames} from '../../../constants.js'
45
import {dirname, joinPath, normalizePath, relativePath} from '@shopify/cli-kit/node/path'
56
import {FSWatcher} from 'chokidar'
@@ -223,6 +224,17 @@ export class FileWatcher {
223224
private shouldIgnoreEvent(event: WatcherEvent) {
224225
if (event.type === 'extension_folder_deleted' || event.type === 'extension_folder_created') return false
225226

227+
// If this path is already tracked for this handle (either pre-registered or
228+
// discovered at runtime), accept without re-checking against the static list.
229+
// The map is keyed by normalized paths, so normalize before the lookup —
230+
// chokidar can emit backslash-separated paths on Windows.
231+
if (
232+
event.extensionHandle &&
233+
this.extensionWatchedFiles.get(normalizePath(event.path))?.has(event.extensionHandle)
234+
) {
235+
return false
236+
}
237+
226238
const extension = event.extensionHandle
227239
? this.app.realExtensions.find((ext) => ext.handle === event.extensionHandle)
228240
: undefined
@@ -251,8 +263,20 @@ export class FileWatcher {
251263
if (isConfigAppPath) {
252264
this.handleEventForExtension(event, path, this.app.directory, startTime, false)
253265
} else {
254-
const affectedHandles = this.extensionWatchedFiles.get(normalizedPath)
255-
const isUnknownExtension = affectedHandles === undefined || affectedHandles.size === 0
266+
let affectedHandles = this.extensionWatchedFiles.get(normalizedPath)
267+
let isUnknownExtension = affectedHandles === undefined || affectedHandles.size === 0
268+
269+
// For 'add' events on paths we don't yet track, try to attribute them to an
270+
// existing extension by directory containment + watch pattern matching. This
271+
// is what allows files created during a running dev session to be picked up.
272+
if (isUnknownExtension && event === 'add' && !isExtensionToml) {
273+
const discovered = this.discoverFileOwners(normalizedPath)
274+
if (discovered.size > 0) {
275+
this.extensionWatchedFiles.set(normalizedPath, discovered)
276+
affectedHandles = discovered
277+
isUnknownExtension = false
278+
}
279+
}
256280

257281
if (isUnknownExtension && !isExtensionToml && !isConfigAppPath) {
258282
// Ignore an event if it's not part of an existing extension
@@ -273,6 +297,35 @@ export class FileWatcher {
273297
this.debouncedEmit()
274298
}
275299

300+
/**
301+
* Returns the handles of every extension that should track the given path,
302+
* based on directory containment and the extension's watch patterns.
303+
* A path can be owned by multiple extensions when they share a directory.
304+
*/
305+
private discoverFileOwners(normalizedPath: string): Set<string> {
306+
const owners = new Set<string>()
307+
for (const extension of this.app.realExtensions) {
308+
const extensionDir = normalizePath(extension.directory)
309+
if (extensionDir === this.app.directory) continue
310+
if (!normalizedPath.startsWith(`${extensionDir}/`)) continue
311+
if (this.pathMatchesWatchPatterns(normalizedPath, extension)) {
312+
owners.add(extension.handle)
313+
}
314+
}
315+
return owners
316+
}
317+
318+
/**
319+
* Tests a path against an extension's watch patterns (paths included, ignore
320+
* excluded). Patterns are matched relative to the extension directory.
321+
*/
322+
private pathMatchesWatchPatterns(normalizedPath: string, extension: ExtensionInstance): boolean {
323+
const {paths, ignore: ignorePatterns} = extension.watchPatterns()
324+
const relative = relativePath(normalizePath(extension.directory), normalizedPath)
325+
if (ignorePatterns.some((pattern) => matchGlob(relative, pattern))) return false
326+
return paths.some((pattern) => matchGlob(relative, pattern))
327+
}
328+
276329
private handleEventForExtension(
277330
event: string,
278331
path: string,
@@ -339,6 +392,10 @@ export class FileWatcher {
339392
// If the extensionPath is not longer in the list, the extension was deleted while the timeout was running.
340393
if (!this.extensionPaths.includes(extensionPath)) return
341394
this.pushEvent({type: 'file_deleted', path, extensionPath, extensionHandle, startTime})
395+
// Drop the path from our ownership map so it doesn't leak across a
396+
// long-running dev session. Subsequent timeouts for other handles
397+
// are no-ops on the now-missing key.
398+
this.extensionWatchedFiles.delete(normalizePath(path))
342399
// Force an emit because we are inside a timeout callback
343400
this.debouncedEmit()
344401
}, FILE_DELETE_TIMEOUT_IN_MS)

0 commit comments

Comments
 (0)