Skip to content

Commit bb48019

Browse files
committed
Preserve polling overrides when FSEvents is available
1 parent 9db779c commit bb48019

7 files changed

Lines changed: 117 additions & 3 deletions

File tree

‎.changeset/cli-ship-fsevents.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
'@shopify/cli': patch
33
---
44

5-
Ship `fsevents` as an optional dependency so file watching on macOS uses native FSEvents instead of polling, which caused high CPU usage during `app dev` and `theme dev`.
5+
Use native FSEvents by default on macOS to reduce `app dev` and `theme dev` CPU use while preserving `CHOKIDAR_USEPOLLING`.

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,10 +235,49 @@ describe('file-watcher events', () => {
235235
],
236236
ignoreInitial: true,
237237
persistent: true,
238+
usePolling: undefined,
238239
})
239240
})
240241
})
241242

243+
test.each([
244+
{value: 'true', usePolling: true},
245+
{value: '1', usePolling: true},
246+
{value: 'TrUe', usePolling: true},
247+
{value: 'on', usePolling: true},
248+
{value: 'false', usePolling: false},
249+
{value: '0', usePolling: false},
250+
{value: undefined, usePolling: false},
251+
])('selects the watcher backend for CHOKIDAR_USEPOLLING=$value', async ({value, usePolling}) => {
252+
await inTemporaryDirectory(async (directory) => {
253+
vi.stubEnv('CHOKIDAR_USEPOLLING', value)
254+
const abortController = new AbortController()
255+
let watcher: FSWatcher | undefined
256+
// Use the real backend selection without starting OS watchers.
257+
vi.spyOn(chokidar, 'watch').mockImplementation((_, options) => {
258+
watcher = new FSWatcher(options)
259+
return watcher
260+
})
261+
const app = testAppLinked({
262+
directory,
263+
configPath: joinPath(directory, 'shopify.app.toml'),
264+
allExtensions: [],
265+
})
266+
const fileWatcher = new FileWatcher(app, {...outputOptions, signal: abortController.signal})
267+
268+
try {
269+
await fileWatcher.start()
270+
271+
expect(Boolean(watcher!.options.usePolling)).toBe(usePolling)
272+
expect(watcher!.options.useFsEvents).toBe(!usePolling && process.platform === 'darwin')
273+
} finally {
274+
abortController.abort()
275+
vi.unstubAllEnvs()
276+
await watcher?.close()
277+
}
278+
})
279+
})
280+
242281
test.each(singleEventTestCases)(
243282
'The event $name returns the expected WatcherEvent',
244283
async ({fileSystemEvent, path, expectedEvent, expectedEventCount, expectedHandles}) => {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export class FileWatcher {
136136

137137
// Create new watcher
138138
const {default: chokidar} = await import('chokidar')
139+
const pollingOverride = process.env.CHOKIDAR_USEPOLLING?.toLowerCase()
139140
const cargoProjects = new Map<string, boolean>()
140141
this.watcher = chokidar.watch(watchPaths, {
141142
ignored: [
@@ -152,6 +153,8 @@ export class FileWatcher {
152153
],
153154
persistent: true,
154155
ignoreInitial: true,
156+
// Chokidar selects FSEvents before it reads the environment override.
157+
usePolling: pollingOverride ? !['false', '0'].includes(pollingOverride) : undefined,
155158
})
156159

157160
this.watcher.on('all', this.handleFileEvent)

‎packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-fs.test.ts‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import {mountThemeExtensionFileSystem} from './theme-ext-fs.js'
2-
import {test, describe, expect} from 'vitest'
2+
import {test, describe, expect, vi} from 'vitest'
3+
import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs'
4+
import chokidar, {FSWatcher} from 'chokidar'
35
import {dirname, joinPath} from '@shopify/cli-kit/node/path'
46
import {fileURLToPath} from 'node:url'
57
import type {Checksum, ThemeAsset} from '@shopify/cli-kit/node/themes/types'
@@ -8,6 +10,38 @@ describe('theme-ext-fs', () => {
810
const locationOfThisFile = dirname(fileURLToPath(import.meta.url))
911

1012
describe('mountThemeExtensionFileSystem', async () => {
13+
test.each([
14+
{value: 'true', usePolling: true},
15+
{value: '1', usePolling: true},
16+
{value: 'TrUe', usePolling: true},
17+
{value: 'on', usePolling: true},
18+
{value: 'false', usePolling: false},
19+
{value: '0', usePolling: false},
20+
{value: undefined, usePolling: false},
21+
])('selects the watcher backend for CHOKIDAR_USEPOLLING=$value', async ({value, usePolling}) => {
22+
await inTemporaryDirectory(async (directory) => {
23+
vi.stubEnv('CHOKIDAR_USEPOLLING', value)
24+
let watcher: FSWatcher | undefined
25+
// Use the real backend selection without starting OS watchers.
26+
vi.spyOn(chokidar, 'watch').mockImplementation((_, options) => {
27+
watcher = new FSWatcher(options)
28+
return watcher
29+
})
30+
31+
try {
32+
const fileSystem = mountThemeExtensionFileSystem(directory)
33+
await fileSystem.ready()
34+
await fileSystem.startWatcher()
35+
36+
expect(Boolean(watcher!.options.usePolling)).toBe(usePolling)
37+
expect(watcher!.options.useFsEvents).toBe(!usePolling && process.platform === 'darwin')
38+
} finally {
39+
vi.unstubAllEnvs()
40+
await watcher?.close()
41+
}
42+
})
43+
})
44+
1145
test('mounts the local theme file system when the directory is valid', async () => {
1246
// Given
1347
const root = joinPath(locationOfThisFile, '../fixtures/theme-ext')

‎packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-fs.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,14 @@ export function mountThemeExtensionFileSystem(root: string): ThemeExtensionFileS
114114
},
115115
startWatcher: async () => {
116116
const {default: chokidar} = await import('chokidar')
117+
const pollingOverride = process.env.CHOKIDAR_USEPOLLING?.toLowerCase()
117118

118119
const watcher = chokidar.watch([...directoriesToWatch], {
119120
ignored: DEFAULT_IGNORE_PATTERNS,
120121
persistent: !process.env.SHOPIFY_UNIT_TEST,
121122
ignoreInitial: true,
123+
// Chokidar selects FSEvents before it reads the environment override.
124+
usePolling: pollingOverride ? !['false', '0'].includes(pollingOverride) : undefined,
122125
})
123126

124127
watcher

‎packages/theme/src/cli/utilities/theme-fs.test.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
} from '@shopify/cli-kit/node/fs'
2323
import * as fsKit from '@shopify/cli-kit/node/fs'
2424
import {test, describe, expect, vi, beforeEach} from 'vitest'
25-
import chokidar from 'chokidar'
25+
import chokidar, {FSWatcher} from 'chokidar'
2626
import {bulkUploadThemeAssets, deleteThemeAssets, fetchThemeAssets} from '@shopify/cli-kit/node/themes/api'
2727
import {renderError} from '@shopify/cli-kit/node/ui'
2828
import {Operation, type Checksum, type ThemeAsset} from '@shopify/cli-kit/node/themes/types'
@@ -66,6 +66,38 @@ describe('theme-fs', () => {
6666
})
6767

6868
describe('mountThemeFileSystem', async () => {
69+
test.each([
70+
{value: 'true', usePolling: true},
71+
{value: '1', usePolling: true},
72+
{value: 'TrUe', usePolling: true},
73+
{value: 'on', usePolling: true},
74+
{value: 'false', usePolling: false},
75+
{value: '0', usePolling: false},
76+
{value: undefined, usePolling: false},
77+
])('selects the watcher backend for CHOKIDAR_USEPOLLING=$value', async ({value, usePolling}) => {
78+
await inTemporaryDirectory(async (directory) => {
79+
vi.stubEnv('CHOKIDAR_USEPOLLING', value)
80+
let watcher: FSWatcher | undefined
81+
// Use the real backend selection without starting OS watchers.
82+
vi.spyOn(chokidar, 'watch').mockImplementation((_, options) => {
83+
watcher = new FSWatcher(options)
84+
return watcher
85+
})
86+
87+
try {
88+
const fileSystem = mountThemeFileSystem(directory)
89+
await fileSystem.ready()
90+
await fileSystem.startWatcher('123', {token: 'token'} as AdminSession)
91+
92+
expect(Boolean(watcher!.options.usePolling)).toBe(usePolling)
93+
expect(watcher!.options.useFsEvents).toBe(!usePolling && process.platform === 'darwin')
94+
} finally {
95+
vi.unstubAllEnvs()
96+
await watcher?.close()
97+
}
98+
})
99+
})
100+
69101
test('mounts the local theme file system when the directory is valid', async () => {
70102
await inTemporaryDirectory(async (tmpDir) => {
71103
// Given

‎packages/theme/src/cli/utilities/theme-fs.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,11 +329,14 @@ export function mountThemeFileSystem(root: string, options?: ThemeFileSystemOpti
329329
},
330330
startWatcher: async (themeId: string, adminSession: AdminSession) => {
331331
const {default: chokidar} = await import('chokidar')
332+
const pollingOverride = process.env.CHOKIDAR_USEPOLLING?.toLowerCase()
332333

333334
const watcher = chokidar.watch([...directoriesToWatch], {
334335
ignored: DEFAULT_IGNORE_PATTERNS,
335336
persistent: !process.env.SHOPIFY_UNIT_TEST,
336337
ignoreInitial: true,
338+
// Chokidar selects FSEvents before it reads the environment override.
339+
usePolling: pollingOverride ? !['false', '0'].includes(pollingOverride) : undefined,
337340
})
338341

339342
// Debounce file events per-file and per-event-type

0 commit comments

Comments
 (0)