From 22c51c4b4c88060bf5cd9afc53412712af700743 Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:17:32 -0500 Subject: [PATCH] Fix app config file watching on Windows Chokidar delivers paths with backslashes on Windows, but the app configuration stores paths with forward slashes. The raw string comparison caused config changes to be silently ignored on Windows. Use normalizePath on both sides of the comparison to ensure consistent slash direction across platforms. Co-Authored-By: Claude Opus 4.6 --- .../dev/app-events/file-watcher.test.ts | 58 +++++++++++++++++++ .../services/dev/app-events/file-watcher.ts | 12 ++-- 2 files changed, 64 insertions(+), 6 deletions(-) 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 5704a97ed6a..131e827a180 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 @@ -663,6 +663,64 @@ describe('file-watcher events', () => { mockedExtractImportPaths.mockReset() }) + test('detects app config changes when chokidar reports backslash paths (Windows)', async () => { + // Simulates the Windows scenario where chokidar returns paths with backslashes + // but the app configuration stores paths with forward slashes + let eventHandler: any + + mockExtensionWatchedFiles(extension1, ['/extensions/ui_extension_1/index.js']) + mockExtensionWatchedFiles(extension1B, ['/extensions/ui_extension_1/index.js']) + mockExtensionWatchedFiles(extension2, ['/extensions/ui_extension_2/index.js']) + mockExtensionWatchedFiles(functionExtension, ['/extensions/my-function/src/index.js']) + mockExtensionWatchedFiles(posExtension, []) + mockExtensionWatchedFiles(appAccessExtension, []) + + const testApp = { + ...defaultApp, + allExtensions: defaultApp.allExtensions, + nonConfigExtensions: defaultApp.allExtensions.filter((ext) => !ext.isAppConfigExtension), + realExtensions: defaultApp.allExtensions, + } + + const mockWatcher = { + on: vi.fn((event: string, listener: any) => { + if (event === 'all') { + eventHandler = listener + } + return mockWatcher + }), + close: vi.fn(() => Promise.resolve()), + } + vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any) + + const fileWatcher = new FileWatcher(testApp, outputOptions, 50) + const onChange = vi.fn() + fileWatcher.onChange(onChange) + + await fileWatcher.start() + await flushPromises() + + // Fire event with backslash path (as chokidar would on Windows) + // while app.configuration.path uses forward slashes + await eventHandler('change', '\\shopify.app.toml') + + await vi.waitFor( + () => { + expect(onChange).toHaveBeenCalled() + const calls = onChange.mock.calls + const actualEvents = calls.find((call) => call[0].length > 0)?.[0] + + if (!actualEvents) { + throw new Error('Expected onChange to be called with events, but all calls had empty arrays') + } + + expect(actualEvents).toHaveLength(1) + expect(actualEvents[0].type).toBe('extensions_config_updated') + }, + {timeout: 1000, interval: 50}, + ) + }) + test('handles rapid file changes without hanging', async () => { let eventHandler: any const events: WatcherEvent[] = [] 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 56fca818a56..7893c5d1b8b 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 @@ -230,13 +230,13 @@ export class FileWatcher { private readonly handleFileEvent = (event: string, path: string) => { const startTime = startHRTime() const normalizedPath = normalizePath(path) - const isConfigAppPath = path === this.app.configuration.path + const isConfigAppPath = normalizedPath === normalizePath(this.app.configuration.path) const isExtensionToml = path.endsWith('.extension.toml') outputDebug(`🌀: ${event} ${path.replace(this.app.directory, '')}\n`) if (isConfigAppPath) { - this.handleEventForExtension(event, path, this.app.directory, startTime, false) + this.handleEventForExtension(event, path, this.app.directory, startTime, false, isExtensionToml, isConfigAppPath) } else { const affectedExtensions = this.extensionWatchedFiles.get(normalizedPath) const isUnknownExtension = affectedExtensions === undefined || affectedExtensions.size === 0 @@ -249,10 +249,10 @@ export class FileWatcher { } for (const extensionPath of affectedExtensions ?? []) { - this.handleEventForExtension(event, path, extensionPath, startTime, false) + this.handleEventForExtension(event, path, extensionPath, startTime, false, isExtensionToml, isConfigAppPath) } if (isUnknownExtension) { - this.handleEventForExtension(event, path, this.app.directory, startTime, true) + this.handleEventForExtension(event, path, this.app.directory, startTime, true, isExtensionToml, isConfigAppPath) } } this.debouncedEmit() @@ -264,9 +264,9 @@ export class FileWatcher { extensionPath: string, startTime: StartTime, isUnknownExtension: boolean, + isExtensionToml: boolean, + isConfigAppPath: boolean, ) { - const isExtensionToml = path.endsWith('.extension.toml') - const isConfigAppPath = path === this.app.configuration.path switch (event) { case 'change':