Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-ship-fsevents.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
Expand Up @@ -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}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean>()
this.watcher = chokidar.watch(watchPaths, {
ignored: [
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/bin/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
"esbuild": "0.28.1",
"global-agent": "3.0.0"
},
"optionalDependencies": {
"fsevents": "2.3.3"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve the polling override on macOS

Could we honor CHOKIDAR_USEPOLLING=true (and =1) when enabling FSEvents? With Chokidar 3.6.0, adding fsevents leaves useFsEvents: true despite that override, so the watcher still uses the native backend. I verified the published snapshots: 0.0.0-snapshot-20261023095012 selects polling, while 0.0.0-snapshot-20261023095653 selects FSEvents with the same override.

Users who rely on polling for network-mounted projects could miss source updates in app dev or theme dev. Please translate the override into an explicit watcher option at the app and theme watcher creation sites, and add regression coverage for the selected backend.

},
"devDependencies": {
"@oclif/core": "4.8.3",
"@oclif/plugin-commands": "4.1.60",
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 33 additions & 1 deletion packages/theme/src/cli/utilities/theme-fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/theme/src/cli/utilities/theme-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading