Skip to content

Commit a100399

Browse files
committed
Watch assets for hot reloading
1 parent c3e54be commit a100399

2 files changed

Lines changed: 178 additions & 3 deletions

File tree

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

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,4 +844,159 @@ describe('file-watcher events', () => {
844844
expect(watchedPaths[1]).toEqual(watchedPaths[0])
845845
})
846846
})
847+
848+
describe('new file directory containment fallback', () => {
849+
test('new file under an existing extension dir triggers file_created with the correct extensionHandle', async () => {
850+
// Given: extension started with no files in assets/ — the new path is not in the snapshot
851+
const ext = await testUIExtension({
852+
type: 'ui_extension',
853+
handle: 'asset_ext',
854+
directory: '/extensions/asset_ext',
855+
})
856+
const newFilePath = '/extensions/asset_ext/assets/dog.jpg'
857+
// At start(), no files are known. After the new file is added, watchedFiles() reflects it
858+
// (representing the live globSync result picking up the new file on disk).
859+
let watchedFilesResult: string[] = []
860+
vi.spyOn(ext, 'watchedFiles').mockImplementation(() => watchedFilesResult)
861+
862+
const testApp = testAppLinked({
863+
allExtensions: [ext],
864+
directory: '/',
865+
configPath: '/shopify.app.toml',
866+
configuration: {
867+
...DEFAULT_CONFIG,
868+
extension_directories: ['/extensions'],
869+
} as any,
870+
})
871+
872+
let eventHandler: any
873+
const mockWatcher = {
874+
on: vi.fn((event: string, listener: any) => {
875+
if (event === 'all') eventHandler = listener
876+
return mockWatcher
877+
}),
878+
close: vi.fn().mockResolvedValue(undefined),
879+
}
880+
vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any)
881+
vi.mocked(fileExistsSync).mockReturnValue(false)
882+
883+
const fileWatcher = new FileWatcher(testApp, outputOptions, 50)
884+
const onChange = vi.fn()
885+
fileWatcher.onChange(onChange)
886+
await fileWatcher.start()
887+
await flushPromises()
888+
889+
// Now simulate the new file being on disk and chokidar firing `add`
890+
watchedFilesResult = [newFilePath]
891+
await eventHandler('add', newFilePath, undefined)
892+
await sleep(0.15)
893+
894+
// Then
895+
await vi.waitFor(
896+
() => {
897+
expect(onChange).toHaveBeenCalled()
898+
const calls = onChange.mock.calls
899+
const actualEvents = calls.find((call) => call[0].length > 0)?.[0]
900+
expect(actualEvents).toBeDefined()
901+
expect(actualEvents).toHaveLength(1)
902+
expect(actualEvents[0].type).toBe('file_created')
903+
expect(actualEvents[0].path).toBe(normalizePath(newFilePath))
904+
expect(actualEvents[0].extensionHandle).toBe('asset_ext')
905+
expect(actualEvents[0].extensionPath).toBe(normalizePath('/extensions/asset_ext'))
906+
},
907+
{timeout: 1000, interval: 50},
908+
)
909+
})
910+
911+
test('new file outside any extension directory is still ignored', async () => {
912+
// Given
913+
const ext = await testUIExtension({
914+
type: 'ui_extension',
915+
handle: 'asset_ext',
916+
directory: '/extensions/asset_ext',
917+
})
918+
vi.spyOn(ext, 'watchedFiles').mockReturnValue([])
919+
920+
const testApp = testAppLinked({
921+
allExtensions: [ext],
922+
directory: '/',
923+
configPath: '/shopify.app.toml',
924+
configuration: {
925+
...DEFAULT_CONFIG,
926+
extension_directories: ['/extensions'],
927+
} as any,
928+
})
929+
930+
let eventHandler: any
931+
const mockWatcher = {
932+
on: vi.fn((event: string, listener: any) => {
933+
if (event === 'all') eventHandler = listener
934+
return mockWatcher
935+
}),
936+
close: vi.fn().mockResolvedValue(undefined),
937+
}
938+
vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any)
939+
vi.mocked(fileExistsSync).mockReturnValue(false)
940+
941+
const fileWatcher = new FileWatcher(testApp, outputOptions, 50)
942+
const onChange = vi.fn()
943+
fileWatcher.onChange(onChange)
944+
await fileWatcher.start()
945+
await flushPromises()
946+
947+
// When: a new file is added outside any extension directory
948+
await eventHandler('add', '/some/unrelated/dir/foo.txt', undefined)
949+
await sleep(0.15)
950+
951+
// Then: no event should be emitted
952+
const hasNonEmptyCall = onChange.mock.calls.some((call) => call[0].length > 0)
953+
expect(hasNonEmptyCall).toBe(false)
954+
})
955+
956+
test('files under an extension dir but excluded by watchedFiles patterns remain filtered', async () => {
957+
// Given: the extension's live watchedFiles() never includes the new path
958+
// (representing a path excluded by devSessionWatchConfig.ignore or .gitignore).
959+
const ext = await testUIExtension({
960+
type: 'ui_extension',
961+
handle: 'asset_ext',
962+
directory: '/extensions/asset_ext',
963+
})
964+
vi.spyOn(ext, 'watchedFiles').mockReturnValue([])
965+
966+
const testApp = testAppLinked({
967+
allExtensions: [ext],
968+
directory: '/',
969+
configPath: '/shopify.app.toml',
970+
configuration: {
971+
...DEFAULT_CONFIG,
972+
extension_directories: ['/extensions'],
973+
} as any,
974+
})
975+
976+
let eventHandler: any
977+
const mockWatcher = {
978+
on: vi.fn((event: string, listener: any) => {
979+
if (event === 'all') eventHandler = listener
980+
return mockWatcher
981+
}),
982+
close: vi.fn().mockResolvedValue(undefined),
983+
}
984+
vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any)
985+
vi.mocked(fileExistsSync).mockReturnValue(false)
986+
987+
const fileWatcher = new FileWatcher(testApp, outputOptions, 50)
988+
const onChange = vi.fn()
989+
fileWatcher.onChange(onChange)
990+
await fileWatcher.start()
991+
await flushPromises()
992+
993+
// When: a new file under the extension dir but not in watchedFiles() is added
994+
await eventHandler('add', '/extensions/asset_ext/dist/built.js', undefined)
995+
await sleep(0.15)
996+
997+
// Then: shouldIgnoreEvent must still drop it
998+
const hasNonEmptyCall = onChange.mock.calls.some((call) => call[0].length > 0)
999+
expect(hasNonEmptyCall).toBe(false)
1000+
})
1001+
})
8471002
})

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

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,9 +255,29 @@ export class FileWatcher {
255255
const isUnknownExtension = affectedHandles === undefined || affectedHandles.size === 0
256256

257257
if (isUnknownExtension && !isExtensionToml && !isConfigAppPath) {
258-
// Ignore an event if it's not part of an existing extension
259-
// Except if it is a toml file (either app config or extension config)
260-
outputDebug(`🌀: File ${path} is not watched by any extension`, this.options.stdout)
258+
// Path isn't in the snapshot — but it may still belong to an extension
259+
// whose directory contains it (e.g. a newly-created file). Attribute by
260+
// directory containment so chokidar `add` events for new files in
261+
// extension dirs aren't silently dropped. The downstream
262+
// `shouldIgnoreEvent` filter still consults each extension's live
263+
// watchedFiles() patterns and gitignore, so excluded files are filtered.
264+
const owningExtension = this.app.realExtensions.find((ext) => {
265+
const extDir = normalizePath(ext.directory)
266+
return normalizedPath === extDir || normalizedPath.startsWith(`${extDir}/`)
267+
})
268+
if (!owningExtension) {
269+
outputDebug(`🌀: File ${path} is not watched by any extension`, this.options.stdout)
270+
return
271+
}
272+
this.handleEventForExtension(
273+
event,
274+
path,
275+
normalizePath(owningExtension.directory),
276+
startTime,
277+
false,
278+
owningExtension.handle,
279+
)
280+
this.debouncedEmit()
261281
return
262282
}
263283

0 commit comments

Comments
 (0)