diff --git a/.changeset/fix-story-watching-chokidar-v4.md b/.changeset/fix-story-watching-chokidar-v4.md new file mode 100644 index 00000000..62a31708 --- /dev/null +++ b/.changeset/fix-story-watching-chokidar-v4.md @@ -0,0 +1,13 @@ +--- +"@ladle/react": patch +--- + +Fix story file watching for add/remove detection with chokidar v4 + +Chokidar v4 no longer supports glob patterns directly, so the watcher now uses picomatch to: + +- Extract base directories from glob patterns using `picomatch.scan()` +- Filter files using the user's configured story patterns (respects custom patterns) +- Properly handle the case where `stats` is undefined during initial directory checks + +This fix ensures that adding or removing story files triggers a full reload as expected. diff --git a/packages/ladle/lib/cli/story-watcher.js b/packages/ladle/lib/cli/story-watcher.js new file mode 100644 index 00000000..2b9be0b9 --- /dev/null +++ b/packages/ladle/lib/cli/story-watcher.js @@ -0,0 +1,31 @@ +import chokidar from "chokidar"; +import picomatch from "picomatch"; + +/** + * Creates a chokidar watcher configured to watch story files. + * @param {string | string[]} storyPatterns - Glob pattern(s) for stories + * @param {object} options - Optional chokidar options override + * @returns {import("chokidar").FSWatcher} + */ +export const createStoryWatcher = (storyPatterns, options = {}) => { + const patterns = Array.isArray(storyPatterns) + ? storyPatterns + : [storyPatterns]; + const baseDirs = [ + ...new Set(patterns.map((p) => picomatch.scan(p).base || ".")), + ]; + const isMatch = picomatch(patterns); + + return chokidar.watch(baseDirs, { + persistent: true, + ignoreInitial: true, + ignored: (filePath, stats) => { + // Don't ignore directories - we need to traverse into them + // In chokidar v4, stats can be undefined for initial directory checks + if (stats === undefined || stats?.isDirectory()) return false; + // Only watch files matching the user's configured story patterns + return !isMatch(filePath); + }, + ...options, + }); +}; diff --git a/packages/ladle/lib/cli/vite-dev.js b/packages/ladle/lib/cli/vite-dev.js index 8380bdfa..a8998e97 100644 --- a/packages/ladle/lib/cli/vite-dev.js +++ b/packages/ladle/lib/cli/vite-dev.js @@ -7,13 +7,13 @@ import path from "path"; import getPort from "get-port"; import { globby } from "globby"; import boxen from "boxen"; -import chokidar from "chokidar"; import openBrowser from "./open-browser.js"; import debug from "./debug.js"; import getBaseViteConfig from "./vite-base.js"; import { getMetaJsonObject } from "./vite-plugin/generate/get-meta-json.js"; import { getEntryData } from "./vite-plugin/parse/get-entry-data.js"; import { connectToKoa } from "./vite-plugin/connect-to-koa.js"; +import { createStoryWatcher } from "./story-watcher.js"; /** * @param config {import("../shared/types").Config} @@ -167,10 +167,7 @@ const bundler = async (config, configFolder) => { if (config.noWatch === false) { // trigger full reload when new stories are added or removed - const watcher = chokidar.watch(config.stories, { - persistent: true, - ignoreInitial: true, - }); + const watcher = createStoryWatcher(config.stories); let checkSum = ""; const getChecksum = async () => { try { diff --git a/packages/ladle/package.json b/packages/ladle/package.json index 9cebff89..672c1fd0 100644 --- a/packages/ladle/package.json +++ b/packages/ladle/package.json @@ -62,6 +62,7 @@ "lodash.merge": "^4.6.2", "msw": "^2.7.0", "open": "^10.1.0", + "picomatch": "^2.3.1", "prism-react-renderer": "^2.4.1", "prop-types": "^15.8.1", "query-string": "^9.1.1", @@ -91,6 +92,7 @@ "@types/express": "^5.0.0", "@types/koa": "^2.15.0", "@types/lodash.merge": "^4.6.9", + "@types/picomatch": "^2.3.1", "@types/node": "^22.10.2", "@types/ws": "^8.5.13", "cross-env": "^7.0.3", diff --git a/packages/ladle/tests/story-watcher.test.ts b/packages/ladle/tests/story-watcher.test.ts new file mode 100644 index 00000000..8668ebd6 --- /dev/null +++ b/packages/ladle/tests/story-watcher.test.ts @@ -0,0 +1,254 @@ +import { test, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import { createStoryWatcher } from "../lib/cli/story-watcher.js"; + +// Normalise path separators for cross-platform comparison +const normalisePath = (p: string) => p.replace(/\\/g, "/"); + +// ============================================ +// Integration tests for createStoryWatcher +// ============================================ + +let tempDir: string; + +beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ladle-watcher-test-")); + // Create a stories subdirectory + await fs.mkdir(path.join(tempDir, "stories"), { recursive: true }); +}); + +afterEach(async () => { + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +}); + +test("createStoryWatcher detects new story file", async () => { + const watcher = createStoryWatcher( + normalisePath(path.join(tempDir, "stories/**/*.stories.tsx")), + ); + const addedFiles: string[] = []; + + watcher.on("add", (filePath) => { + addedFiles.push(filePath); + }); + + // Wait for watcher to be ready + await new Promise((resolve) => watcher.on("ready", resolve)); + + // Create a new story file + const storyPath = path.join(tempDir, "stories", "Button.stories.tsx"); + await fs.writeFile(storyPath, 'export const Button = () => "Hello";'); + + // Wait for the watcher to detect the file + await new Promise((resolve) => setTimeout(resolve, 200)); + + await watcher.close(); + + expect(addedFiles.length).toBe(1); + expect(normalisePath(addedFiles[0])).toBe(normalisePath(storyPath)); +}); + +test("createStoryWatcher ignores non-story files", async () => { + const watcher = createStoryWatcher( + normalisePath(path.join(tempDir, "stories/**/*.stories.tsx")), + ); + const addedFiles: string[] = []; + + watcher.on("add", (filePath) => { + addedFiles.push(filePath); + }); + + // Wait for watcher to be ready + await new Promise((resolve) => watcher.on("ready", resolve)); + + // Create a non-story file + const regularFile = path.join(tempDir, "stories", "utils.ts"); + await fs.writeFile(regularFile, 'export const foo = "bar";'); + + // Wait a bit to ensure the watcher had time to process + await new Promise((resolve) => setTimeout(resolve, 200)); + + await watcher.close(); + + expect(addedFiles.length).toBe(0); +}); + +test("createStoryWatcher detects story file deletion", async () => { + // Create the story file first + const storyPath = path.join(tempDir, "stories", "Button.stories.tsx"); + await fs.writeFile(storyPath, 'export const Button = () => "Hello";'); + + const watcher = createStoryWatcher( + normalisePath(path.join(tempDir, "stories/**/*.stories.tsx")), + ); + const deletedFiles: string[] = []; + + watcher.on("unlink", (filePath) => { + deletedFiles.push(filePath); + }); + + // Wait for watcher to be ready + await new Promise((resolve) => watcher.on("ready", resolve)); + + // Delete the story file + await fs.unlink(storyPath); + + // Wait for the watcher to detect the deletion + await new Promise((resolve) => setTimeout(resolve, 200)); + + await watcher.close(); + + expect(deletedFiles.length).toBe(1); + expect(normalisePath(deletedFiles[0])).toBe(normalisePath(storyPath)); +}); + +test("createStoryWatcher detects story file changes", async () => { + // Create the story file first + const storyPath = path.join(tempDir, "stories", "Button.stories.tsx"); + await fs.writeFile(storyPath, 'export const Button = () => "Hello";'); + + const watcher = createStoryWatcher( + normalisePath(path.join(tempDir, "stories/**/*.stories.tsx")), + ); + const changedFiles: string[] = []; + + watcher.on("change", (filePath) => { + changedFiles.push(filePath); + }); + + // Wait for watcher to be ready + await new Promise((resolve) => watcher.on("ready", resolve)); + + // Modify the story file + await fs.writeFile(storyPath, 'export const Button = () => "World";'); + + // Wait for the watcher to detect the change + await new Promise((resolve) => setTimeout(resolve, 200)); + + await watcher.close(); + + expect(changedFiles.length).toBe(1); + expect(normalisePath(changedFiles[0])).toBe(normalisePath(storyPath)); +}); + +test("createStoryWatcher handles multiple patterns (array)", async () => { + // Create additional directory + await fs.mkdir(path.join(tempDir, "components"), { recursive: true }); + + const watcher = createStoryWatcher([ + normalisePath(path.join(tempDir, "stories/**/*.stories.tsx")), + normalisePath(path.join(tempDir, "components/**/*.stories.tsx")), + ]); + const addedFiles: string[] = []; + + watcher.on("add", (filePath) => { + addedFiles.push(filePath); + }); + + // Wait for watcher to be ready + await new Promise((resolve) => watcher.on("ready", resolve)); + + // Create story files in both directories + const storyPath1 = path.join(tempDir, "stories", "Story1.stories.tsx"); + const storyPath2 = path.join(tempDir, "components", "Story2.stories.tsx"); + await fs.writeFile(storyPath1, 'export const Story1 = () => "One";'); + await fs.writeFile(storyPath2, 'export const Story2 = () => "Two";'); + + // Wait for the watcher to detect the files + await new Promise((resolve) => setTimeout(resolve, 300)); + + await watcher.close(); + + expect(addedFiles.length).toBe(2); + expect(addedFiles.map(normalisePath)).toContain(normalisePath(storyPath1)); + expect(addedFiles.map(normalisePath)).toContain(normalisePath(storyPath2)); +}); + +test("createStoryWatcher detects stories in nested directories", async () => { + // Create nested directory structure + await fs.mkdir(path.join(tempDir, "stories", "buttons", "primary"), { + recursive: true, + }); + + const watcher = createStoryWatcher( + normalisePath(path.join(tempDir, "stories/**/*.stories.tsx")), + ); + const addedFiles: string[] = []; + + watcher.on("add", (filePath) => { + addedFiles.push(filePath); + }); + + // Wait for watcher to be ready + await new Promise((resolve) => watcher.on("ready", resolve)); + + // Create a story file in a nested directory + const storyPath = path.join( + tempDir, + "stories", + "buttons", + "primary", + "PrimaryButton.stories.tsx", + ); + await fs.writeFile(storyPath, 'export const PrimaryButton = () => "Click";'); + + // Wait for the watcher to detect the file + await new Promise((resolve) => setTimeout(resolve, 200)); + + await watcher.close(); + + expect(addedFiles.length).toBe(1); + expect(normalisePath(addedFiles[0])).toBe(normalisePath(storyPath)); +}); + +test("createStoryWatcher matches all story file extensions", async () => { + // Use a proper story glob pattern (like the default config) + const watcher = createStoryWatcher( + normalisePath( + path.join(tempDir, "stories/**/*.stories.{js,jsx,ts,tsx,mdx}"), + ), + ); + const addedFiles: string[] = []; + + watcher.on("add", (filePath) => { + addedFiles.push(filePath); + }); + + // Wait for watcher to be ready + await new Promise((resolve) => watcher.on("ready", resolve)); + + // Create story files with different extensions + const extensions = ["js", "jsx", "ts", "tsx", "mdx"]; + for (const ext of extensions) { + const storyPath = path.join(tempDir, "stories", `Test.stories.${ext}`); + await fs.writeFile(storyPath, `// ${ext} story file`); + } + + // Create non-story files that should be ignored + await fs.writeFile( + path.join(tempDir, "stories", "utils.ts"), + "// utility file", + ); + await fs.writeFile( + path.join(tempDir, "stories", "Button.tsx"), + "// component file", + ); + + // Wait for the watcher to detect the files + await new Promise((resolve) => setTimeout(resolve, 300)); + + await watcher.close(); + + // Should detect all 5 story files but not the 2 non-story files + expect(addedFiles.length).toBe(5); + for (const ext of extensions) { + expect( + addedFiles.some((f) => f.endsWith(`Test.stories.${ext}`)), + ).toBeTruthy(); + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2f8bdd1..156cc43d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -458,6 +458,9 @@ importers: open: specifier: ^10.1.0 version: 10.1.0 + picomatch: + specifier: ^2.3.1 + version: 2.3.1 prism-react-renderer: specifier: ^2.4.1 version: 2.4.1(react@19.0.0) @@ -531,6 +534,9 @@ importers: '@types/node': specifier: ^22.10.2 version: 22.10.2 + '@types/picomatch': + specifier: ^2.3.1 + version: 2.3.4 '@types/ws': specifier: ^8.5.13 version: 8.5.13 @@ -2885,6 +2891,9 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/picomatch@2.3.4': + resolution: {integrity: sha512-0so8lU8O5zatZS/2Fi4zrwks+vZv7e0dygrgEZXljODXBig97l4cPQD+9LabXfGJOWwoRkTVz6Q4edZvD12UOA==} + '@types/prismjs@1.26.5': resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} @@ -11884,6 +11893,8 @@ snapshots: '@types/parse-json@4.0.2': {} + '@types/picomatch@2.3.4': {} + '@types/prismjs@1.26.5': {} '@types/qs@6.9.17': {}