diff --git a/.changeset/cli-ship-fsevents.md b/.changeset/cli-ship-fsevents.md new file mode 100644 index 00000000000..f7d4a874455 --- /dev/null +++ b/.changeset/cli-ship-fsevents.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': patch +--- + +Use native FSEvents by default on macOS to reduce `app dev` and `theme dev` CPU use while preserving `CHOKIDAR_USEPOLLING`. 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 656f890f103..7cb3c39e9ff 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 @@ -235,10 +235,49 @@ describe('file-watcher events', () => { ], ignoreInitial: true, persistent: true, + usePolling: undefined, }) }) }) + test.each([ + {value: 'true', usePolling: true}, + {value: '1', usePolling: true}, + {value: 'TrUe', usePolling: true}, + {value: 'on', usePolling: true}, + {value: 'false', usePolling: false}, + {value: '0', usePolling: false}, + {value: undefined, usePolling: false}, + ])('selects the watcher backend for CHOKIDAR_USEPOLLING=$value', async ({value, usePolling}) => { + await inTemporaryDirectory(async (directory) => { + vi.stubEnv('CHOKIDAR_USEPOLLING', value) + const abortController = new AbortController() + let watcher: FSWatcher | undefined + // Use the real backend selection without starting OS watchers. + vi.spyOn(chokidar, 'watch').mockImplementation((_, options) => { + watcher = new FSWatcher(options) + return watcher + }) + const app = testAppLinked({ + directory, + configPath: joinPath(directory, 'shopify.app.toml'), + allExtensions: [], + }) + const fileWatcher = new FileWatcher(app, {...outputOptions, signal: abortController.signal}) + + try { + await fileWatcher.start() + + expect(Boolean(watcher!.options.usePolling)).toBe(usePolling) + expect(watcher!.options.useFsEvents).toBe(!usePolling && process.platform === 'darwin') + } finally { + abortController.abort() + vi.unstubAllEnvs() + await watcher?.close() + } + }) + }) + test.each(singleEventTestCases)( 'The event $name returns the expected WatcherEvent', async ({fileSystemEvent, path, expectedEvent, expectedEventCount, expectedHandles}) => { 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 89a7a12edee..47ed4630ac2 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 @@ -136,6 +136,7 @@ export class FileWatcher { // Create new watcher const {default: chokidar} = await import('chokidar') + const pollingOverride = process.env.CHOKIDAR_USEPOLLING?.toLowerCase() const cargoProjects = new Map() this.watcher = chokidar.watch(watchPaths, { ignored: [ @@ -152,6 +153,8 @@ export class FileWatcher { ], persistent: true, ignoreInitial: true, + // Chokidar selects FSEvents before it reads the environment override. + usePolling: pollingOverride ? !['false', '0'].includes(pollingOverride) : undefined, }) this.watcher.on('all', this.handleFileEvent) diff --git a/packages/cli/bin/bundle.js b/packages/cli/bin/bundle.js index 0d0979220c2..a1666acb4f8 100644 --- a/packages/cli/bin/bundle.js +++ b/packages/cli/bin/bundle.js @@ -24,6 +24,9 @@ const external = [ '@ast-grep/napi', // clipboardy ships platform-specific fallback binaries that need to remain beside the package source. 'clipboardy', + // fsevents is a macOS-only native module that chokidar loads at runtime. It ships as an optional + // dependency so chokidar uses native FSEvents instead of falling back to polling on macOS. + 'fsevents', ] // yoga wasm file is not bundled by esbuild, so we need to copy it manually diff --git a/packages/cli/package.json b/packages/cli/package.json index 2fc8b0116fd..43695aa6376 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -57,6 +57,9 @@ "esbuild": "0.28.1", "global-agent": "3.0.0" }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, "devDependencies": { "@oclif/core": "4.8.3", "@oclif/plugin-commands": "4.1.60", diff --git a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-fs.test.ts b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-fs.test.ts index b41ac516f54..9617657696c 100644 --- a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-fs.test.ts +++ b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-fs.test.ts @@ -1,5 +1,7 @@ import {mountThemeExtensionFileSystem} from './theme-ext-fs.js' -import {test, describe, expect} from 'vitest' +import {test, describe, expect, vi} from 'vitest' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import chokidar, {FSWatcher} from 'chokidar' import {dirname, joinPath} from '@shopify/cli-kit/node/path' import {fileURLToPath} from 'node:url' import type {Checksum, ThemeAsset} from '@shopify/cli-kit/node/themes/types' @@ -8,6 +10,38 @@ describe('theme-ext-fs', () => { const locationOfThisFile = dirname(fileURLToPath(import.meta.url)) describe('mountThemeExtensionFileSystem', async () => { + test.each([ + {value: 'true', usePolling: true}, + {value: '1', usePolling: true}, + {value: 'TrUe', usePolling: true}, + {value: 'on', usePolling: true}, + {value: 'false', usePolling: false}, + {value: '0', usePolling: false}, + {value: undefined, usePolling: false}, + ])('selects the watcher backend for CHOKIDAR_USEPOLLING=$value', async ({value, usePolling}) => { + await inTemporaryDirectory(async (directory) => { + vi.stubEnv('CHOKIDAR_USEPOLLING', value) + let watcher: FSWatcher | undefined + // Use the real backend selection without starting OS watchers. + vi.spyOn(chokidar, 'watch').mockImplementation((_, options) => { + watcher = new FSWatcher(options) + return watcher + }) + + try { + const fileSystem = mountThemeExtensionFileSystem(directory) + await fileSystem.ready() + await fileSystem.startWatcher() + + expect(Boolean(watcher!.options.usePolling)).toBe(usePolling) + expect(watcher!.options.useFsEvents).toBe(!usePolling && process.platform === 'darwin') + } finally { + vi.unstubAllEnvs() + await watcher?.close() + } + }) + }) + test('mounts the local theme file system when the directory is valid', async () => { // Given const root = joinPath(locationOfThisFile, '../fixtures/theme-ext') diff --git a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-fs.ts b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-fs.ts index 9672ae52f4f..96052c5615c 100644 --- a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-fs.ts +++ b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-fs.ts @@ -114,11 +114,14 @@ export function mountThemeExtensionFileSystem(root: string): ThemeExtensionFileS }, startWatcher: async () => { const {default: chokidar} = await import('chokidar') + const pollingOverride = process.env.CHOKIDAR_USEPOLLING?.toLowerCase() const watcher = chokidar.watch([...directoriesToWatch], { ignored: DEFAULT_IGNORE_PATTERNS, persistent: !process.env.SHOPIFY_UNIT_TEST, ignoreInitial: true, + // Chokidar selects FSEvents before it reads the environment override. + usePolling: pollingOverride ? !['false', '0'].includes(pollingOverride) : undefined, }) watcher diff --git a/packages/theme/src/cli/utilities/theme-fs.test.ts b/packages/theme/src/cli/utilities/theme-fs.test.ts index 66d7c1704f9..aa9a25c2d09 100644 --- a/packages/theme/src/cli/utilities/theme-fs.test.ts +++ b/packages/theme/src/cli/utilities/theme-fs.test.ts @@ -22,7 +22,7 @@ import { } from '@shopify/cli-kit/node/fs' import * as fsKit from '@shopify/cli-kit/node/fs' import {test, describe, expect, vi, beforeEach} from 'vitest' -import chokidar from 'chokidar' +import chokidar, {FSWatcher} from 'chokidar' import {bulkUploadThemeAssets, deleteThemeAssets, fetchThemeAssets} from '@shopify/cli-kit/node/themes/api' import {renderError} from '@shopify/cli-kit/node/ui' import {Operation, type Checksum, type ThemeAsset} from '@shopify/cli-kit/node/themes/types' @@ -66,6 +66,38 @@ describe('theme-fs', () => { }) describe('mountThemeFileSystem', async () => { + test.each([ + {value: 'true', usePolling: true}, + {value: '1', usePolling: true}, + {value: 'TrUe', usePolling: true}, + {value: 'on', usePolling: true}, + {value: 'false', usePolling: false}, + {value: '0', usePolling: false}, + {value: undefined, usePolling: false}, + ])('selects the watcher backend for CHOKIDAR_USEPOLLING=$value', async ({value, usePolling}) => { + await inTemporaryDirectory(async (directory) => { + vi.stubEnv('CHOKIDAR_USEPOLLING', value) + let watcher: FSWatcher | undefined + // Use the real backend selection without starting OS watchers. + vi.spyOn(chokidar, 'watch').mockImplementation((_, options) => { + watcher = new FSWatcher(options) + return watcher + }) + + try { + const fileSystem = mountThemeFileSystem(directory) + await fileSystem.ready() + await fileSystem.startWatcher('123', {token: 'token'} as AdminSession) + + expect(Boolean(watcher!.options.usePolling)).toBe(usePolling) + expect(watcher!.options.useFsEvents).toBe(!usePolling && process.platform === 'darwin') + } finally { + vi.unstubAllEnvs() + await watcher?.close() + } + }) + }) + test('mounts the local theme file system when the directory is valid', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given diff --git a/packages/theme/src/cli/utilities/theme-fs.ts b/packages/theme/src/cli/utilities/theme-fs.ts index 5448fe00ba4..97ff06b23bf 100644 --- a/packages/theme/src/cli/utilities/theme-fs.ts +++ b/packages/theme/src/cli/utilities/theme-fs.ts @@ -329,11 +329,14 @@ export function mountThemeFileSystem(root: string, options?: ThemeFileSystemOpti }, startWatcher: async (themeId: string, adminSession: AdminSession) => { const {default: chokidar} = await import('chokidar') + const pollingOverride = process.env.CHOKIDAR_USEPOLLING?.toLowerCase() const watcher = chokidar.watch([...directoriesToWatch], { ignored: DEFAULT_IGNORE_PATTERNS, persistent: !process.env.SHOPIFY_UNIT_TEST, ignoreInitial: true, + // Chokidar selects FSEvents before it reads the environment override. + usePolling: pollingOverride ? !['false', '0'].includes(pollingOverride) : undefined, }) // Debounce file events per-file and per-event-type diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8251ab9372a..b43513a78fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -315,6 +315,10 @@ importers: esbuild-plugin-copy: specifier: ^2.1.1 version: 2.1.1(esbuild@0.28.1) + optionalDependencies: + fsevents: + specifier: 2.3.3 + version: 2.3.3 packages/cli-kit: dependencies: