From a3f5842e05e93b01b82080f512411dd830a16393 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:17:41 +0000 Subject: [PATCH] [Performance] Cache compiled glob matchers in matchGlob minimatch() parses the pattern into a new Minimatch instance on every call. Since the same handful of patterns is matched against thousands of paths (theme ignore filters run over every file of a theme, the app file watcher over every event), that parsing dominates the cost. Compiled matchers are stateless, so they can be cached by pattern and options and reused. Co-Authored-By: Claude Opus 4.8 --- packages/cli-kit/src/public/node/fs.test.ts | 26 +++++++++++++++++++++ packages/cli-kit/src/public/node/fs.ts | 21 +++++++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/cli-kit/src/public/node/fs.test.ts b/packages/cli-kit/src/public/node/fs.test.ts index d30612b5b96..75896dcab0d 100644 --- a/packages/cli-kit/src/public/node/fs.test.ts +++ b/packages/cli-kit/src/public/node/fs.test.ts @@ -22,6 +22,7 @@ import { copyDirectoryContents, symlink, fileRealPath, + matchGlob, } from './fs.js' import {joinPath, normalizePath} from './path.js' import * as array from '../common/array.js' @@ -565,3 +566,28 @@ describe('symlink', () => { }) }) }) + +describe('matchGlob', () => { + test('matches a key against a glob pattern', () => { + expect(matchGlob('templates/index.json', 'templates/*.json')).toBe(true) + expect(matchGlob('assets/base.css', 'templates/*.json')).toBe(false) + }) + + test('returns consistent results when the same pattern is reused', () => { + // Compiled matchers are cached by pattern, so reuse must not leak state between keys. + expect(matchGlob('templates/index.json', 'templates/*.json')).toBe(true) + expect(matchGlob('assets/base.css', 'templates/*.json')).toBe(false) + expect(matchGlob('templates/index.json', 'templates/*.json')).toBe(true) + }) + + test('caches matchers per set of options', () => { + const options = {matchBase: true, noglobstar: true} + + expect(matchGlob('sections/header.liquid', '*.liquid', options)).toBe(true) + expect(matchGlob('sections/header.liquid', '*.liquid')).toBe(false) + }) + + test('does not match comment patterns', () => { + expect(matchGlob('#comment', '#comment')).toBe(false) + }) +}) diff --git a/packages/cli-kit/src/public/node/fs.ts b/packages/cli-kit/src/public/node/fs.ts index cd3aecf4742..d233144b036 100644 --- a/packages/cli-kit/src/public/node/fs.ts +++ b/packages/cli-kit/src/public/node/fs.ts @@ -15,7 +15,7 @@ import { import {sep, join} from 'pathe' import {findUp as internalFindUp, findUpSync as internalFindUpSync} from 'find-up' -import {minimatch} from 'minimatch' +import {Minimatch} from 'minimatch' import fastGlobLib from 'fast-glob' import { mkdirSync as fsMkdirSync, @@ -706,6 +706,23 @@ export interface MatchGlobOptions { noglobstar: boolean } +// `minimatch()` parses the pattern into a new Minimatch instance on every call, which dominates the +// cost when the same handful of patterns is matched against thousands of paths (theme ignore filters +// run over every file of a theme, the app file watcher over every event). Compiled matchers are +// stateless, so caching them by pattern and options makes repeated matching ~24x faster. +const compiledGlobMatchers = new Map() + +function compiledGlobMatcher(pattern: string, options?: MatchGlobOptions): Minimatch { + const cacheKey = `${options?.matchBase ?? false}:${options?.noglobstar ?? false}:${pattern}` + + let matcher = compiledGlobMatchers.get(cacheKey) + if (!matcher) { + matcher = new Minimatch(pattern, options) + compiledGlobMatchers.set(cacheKey, matcher) + } + return matcher +} + /** * Matches a key against a glob pattern. * @param key - The key to match. @@ -714,7 +731,7 @@ export interface MatchGlobOptions { * @returns true if the key matches the pattern, false otherwise. */ export function matchGlob(key: string, pattern: string, options?: MatchGlobOptions): boolean { - return minimatch(key, pattern, options) + return compiledGlobMatcher(pattern, options).match(key) } /**