From 2f319a512f89b8462bcc7a1d8966a13979219669 Mon Sep 17 00:00:00 2001 From: River Date: Fri, 23 Oct 2026 08:42:26 +0000 Subject: [PATCH] Stop app dev watching build output and other never-source paths Chokidar ignored only node_modules and .git, so it crawled (and on macOS, without fsevents, polled) every file under a Rust function's target directory. The per-extension watch rules only filter events after chokidar emits them. Ignore at the watcher level the paths that are never an extension's source or an import: Cargo target directories (a target directory next to a Cargo.toml), function output directories (dist), the CLI's .shopify directory, .DS_Store and editor swap/backup files. dist and generated stay watched for other extensions, because imported files can live there. Co-authored-by: Isaac Roldan --- .../app-dev-watcher-ignore-build-output.md | 5 + .../dev/app-events/file-watcher.test.ts | 100 +++++++++++++++++- .../services/dev/app-events/file-watcher.ts | 67 +++++++++++- 3 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 .changeset/app-dev-watcher-ignore-build-output.md diff --git a/.changeset/app-dev-watcher-ignore-build-output.md b/.changeset/app-dev-watcher-ignore-build-output.md new file mode 100644 index 00000000000..b103af9362a --- /dev/null +++ b/.changeset/app-dev-watcher-ignore-build-output.md @@ -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. 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 5fad96b49d0..cf2a1b2b486 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 @@ -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 @@ -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) @@ -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, }) @@ -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 | 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) => { 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 cbc86472baa..8ec7ca36bfa 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 @@ -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 * @@ -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, }) @@ -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() + + 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): 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 +}