Skip to content
Draft
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/app-dev-watcher-ignore-build-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': patch
---

`app dev` no longer watches build output (Rust `target` directories and function `dist` directories), the CLI's own `.shopify` files, `.DS_Store` or editor swap files. This cuts file watcher CPU and memory, especially for Rust functions on macOS.
100 changes: 97 additions & 3 deletions packages/app/src/cli/services/dev/app-events/file-watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import {
} from '../../../models/app/app.test-data.js'
import {flushPromises} from '@shopify/cli-kit/node/promises'
import {describe, expect, test, vi} from 'vitest'
import chokidar from 'chokidar'
import chokidar, {FSWatcher} from 'chokidar'
import {AbortController} from '@shopify/cli-kit/node/abort'
import {inTemporaryDirectory, mkdir, writeFile, fileExistsSync} from '@shopify/cli-kit/node/fs'
import {joinPath, normalizePath} from '@shopify/cli-kit/node/path'
import {dirname, joinPath, normalizePath} from '@shopify/cli-kit/node/path'
import {extractImportPathsRecursively} from '@shopify/cli-kit/node/import-extractor'

// Mock the import extractor - will be configured per test
Expand All @@ -22,6 +22,9 @@ vi.mock('@shopify/cli-kit/node/import-extractor', () => ({
extractJSImports: vi.fn(() => []),
}))

// The real chokidar.watch, kept before any test spies on it
const realChokidarWatch = chokidar.watch.bind(chokidar)

// Helper to mock watchedFiles for extensions
function mockExtensionWatchedFiles(extension: any, files: string[] = []) {
vi.spyOn(extension, 'watchedFiles').mockReturnValue(files)
Expand Down Expand Up @@ -221,7 +224,16 @@ describe('file-watcher events', () => {

// Then
expect(watchSpy).toHaveBeenCalledWith([joinPath(dir, 'shopify.app.toml'), joinPath(dir, 'extensions')], {
ignored: ['**/node_modules/**', '**/.git/**'],
ignored: [
'**/node_modules/**',
'**/.git/**',
'**/.shopify/**',
'**/.DS_Store',
'**/*.swp',
'**/*.swo',
'**/*~',
expect.any(Function),
],
ignoreInitial: true,
persistent: true,
})
Expand Down Expand Up @@ -712,6 +724,88 @@ describe('file-watcher events', () => {
})
})

test('chokidar does not watch build output, CLI files or editor temporary files', async () => {
await inTemporaryDirectory(async (dir) => {
// Given
const files = [
// Rust function: Cargo's target directory is build output
'extensions/rust-function/Cargo.toml',
'extensions/rust-function/src/main.rs',
'extensions/rust-function/target/debug/build/output.rlib',
'extensions/rust-function/target/wasm32-wasip1/release/rust-function.wasm',
// JavaScript function: dist is build output
'extensions/js-function/src/index.js',
'extensions/js-function/dist/function.js',
'extensions/js-function/dist/index.wasm',
// UI extension: a source folder named target, and dist, stay watched
'extensions/ui-extension/src/target/index.js',
'extensions/ui-extension/dist/main.js',
'extensions/ui-extension/.DS_Store',
'extensions/ui-extension/src/index.js.swp',
'extensions/ui-extension/src/index.js~',
'extensions/.shopify/dev-bundle/output.js',
]
await Promise.all(
files.map(async (file) => {
await mkdir(dirname(joinPath(dir, file)))
await writeFile(joinPath(dir, file), '')
}),
)
const configPath = joinPath(dir, 'shopify.app.toml')
await writeFile(configPath, '')

const rustFunction = await testFunctionExtension({dir: joinPath(dir, 'extensions/rust-function')})
const jsFunction = await testFunctionExtension({dir: joinPath(dir, 'extensions/js-function')})
const uiExtension = await testUIExtension({directory: joinPath(dir, 'extensions/ui-extension')})
mockExtensionWatchedFiles(uiExtension, [])
const app = testAppLinked({
allExtensions: [rustFunction, jsFunction, uiExtension],
directory: dir,
configPath,
})

let watcher: FSWatcher | undefined
let ready: Promise<void> | undefined
vi.spyOn(chokidar, 'watch').mockImplementation((paths, options) => {
const realWatcher = realChokidarWatch(paths, options)
ready = new Promise((resolve) => realWatcher.on('ready', () => resolve()))
watcher = realWatcher
return realWatcher
})

// When
const fileWatcher = new FileWatcher(app, outputOptions)
await fileWatcher.start()
await ready

// Then
const watched = Object.entries(watcher!.getWatched()).flatMap(([directory, names]) =>
names.map((name) => normalizePath(joinPath(directory, name)).replace(`${normalizePath(dir)}/`, '')),
)
await watcher!.close()

expect(watched).toEqual(
expect.arrayContaining([
'extensions/rust-function/Cargo.toml',
'extensions/rust-function/src/main.rs',
'extensions/js-function/src/index.js',
'extensions/ui-extension/src/target/index.js',
'extensions/ui-extension/dist/main.js',
]),
)
for (const ignored of [
'extensions/rust-function/target',
'extensions/js-function/dist',
'extensions/ui-extension/.DS_Store',
'extensions/ui-extension/src/index.js.swp',
'extensions/ui-extension/src/index.js~',
'extensions/.shopify',
]) {
expect(watched.filter((path) => path === ignored || path.startsWith(`${ignored}/`))).toEqual([])
}
})
})

describe('runtime file discovery', () => {
test('files added at runtime inside an existing extension trigger file_created', async () => {
await inTemporaryDirectory(async (dir) => {
Expand Down
67 changes: 66 additions & 1 deletion packages/app/src/cli/services/dev/app-events/file-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,26 @@ const EXTENSION_CREATION_TIMEOUT_IN_MS = 60000
const EXTENSION_CREATION_CHECK_INTERVAL_IN_MS = 200
const FILE_DELETE_TIMEOUT_IN_MS = 500

/**
* Paths chokidar never watches, whatever extensions the app has. None of them is ever
* an extension's source or a file an extension imports. Skipping them here, rather than
* dropping their events afterwards, stops chokidar crawling them and, when it falls
* back to polling, polling them.
*/
const ALWAYS_IGNORED_PATHS = [
// Dependencies and version control
'**/node_modules/**',
'**/.git/**',
// Files the CLI writes itself: dev and deploy bundles, certificates, app security results
'**/.shopify/**',
// macOS Finder metadata
'**/.DS_Store',
// Editor swap and backup files
'**/*.swp',
'**/*.swo',
'**/*~',
]

/**
* Event emitted by the file watcher
*
Expand Down Expand Up @@ -119,7 +139,7 @@ export class FileWatcher {
// Create new watcher
const {default: chokidar} = await import('chokidar')
this.watcher = chokidar.watch(watchPaths, {
ignored: ['**/node_modules/**', '**/.git/**'],
ignored: [...ALWAYS_IGNORED_PATHS, buildOutputMatcher(this.app)],
persistent: true,
ignoreInitial: true,
})
Expand Down Expand Up @@ -430,3 +450,48 @@ export class FileWatcher {
return ignore.default().add(gitIgnoreContent)
}
}

/**
* Returns a chokidar matcher for build output, which is never source: every function's
* output directory (`dist` by default) and every Cargo `target` directory. A Rust
* function's `target` directory can hold tens of thousands of files.
*/
function buildOutputMatcher(app: AppLinkedInterface): (path: string) => boolean {
const functionOutputDirectories = app.nonConfigExtensions.flatMap((extension) => {
const outputDirectory = dirname(extension.outputRelativePath)
if (!extension.isFunctionExtension || outputDirectory === '.' || outputDirectory === '') return []
return [normalizePath(joinPath(extension.directory, outputDirectory))]
})
const cargoProjects = new Map<string, boolean>()

return (path: string) => {
const normalizedPath = normalizePath(path)
const isFunctionOutput = functionOutputDirectories.some(
(directory) => normalizedPath === directory || normalizedPath.startsWith(`${directory}/`),
)
return isFunctionOutput || isInCargoTargetDirectory(normalizedPath, cargoProjects)
}
}

/**
* Whether a path is inside a Cargo build directory: a `target` directory next to a
* `Cargo.toml`. Requiring the `Cargo.toml` keeps source folders that happen to be
* called `target` watched.
*
* @param path - Normalized path to check.
* @param cargoProjects - Cache of whether a directory holds a `Cargo.toml`, keyed by directory.
*/
function isInCargoTargetDirectory(path: string, cargoProjects: Map<string, boolean>): boolean {
const segments = path.split('/')
for (let index = segments.length - 1; index > 0; index--) {
if (segments[index] !== 'target') continue
const projectDirectory = segments.slice(0, index).join('/')
let isCargoProject = cargoProjects.get(projectDirectory)
if (isCargoProject === undefined) {
isCargoProject = fileExistsSync(joinPath(projectDirectory, 'Cargo.toml'))
cargoProjects.set(projectDirectory, isCargoProject)
}
if (isCargoProject) return true
}
return false
}
Loading