From d87c9cc23f3f3fc275e1a0a54e8211d653e597b1 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Thu, 11 Dec 2025 15:14:08 +0000 Subject: [PATCH 1/4] Fix Chokidar watch so Story add or remove is picked up --- .changeset/fix-story-watching-chokidar-v4.md | 13 + packages/ladle/lib/cli/story-watcher.js | 46 +++ packages/ladle/lib/cli/vite-dev.js | 7 +- packages/ladle/tests/story-watcher.test.ts | 328 +++++++++++++++++++ 4 files changed, 389 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-story-watching-chokidar-v4.md create mode 100644 packages/ladle/lib/cli/story-watcher.js create mode 100644 packages/ladle/tests/story-watcher.test.ts diff --git a/.changeset/fix-story-watching-chokidar-v4.md b/.changeset/fix-story-watching-chokidar-v4.md new file mode 100644 index 00000000..873fff2c --- /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: + +- Extracts base directories from glob patterns using `getGlobBasePath` +- Filters story files using `STORY_FILE_REGEX` in the `ignored` callback +- Properly handles 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..6a70933e --- /dev/null +++ b/packages/ladle/lib/cli/story-watcher.js @@ -0,0 +1,46 @@ +import chokidar from "chokidar"; + +/** + * Extract the base directory from a glob pattern. + * e.g., "src/**\/*.stories.tsx" -> "src" + * @param {string} pattern + * @returns {string} + */ +export const getGlobBasePath = (pattern) => { + const parts = pattern.split("/"); + const baseParts = []; + for (const part of parts) { + if (part.includes("*") || part.includes("{") || part.includes("[")) break; + baseParts.push(part); + } + return baseParts.length > 0 ? baseParts.join("/") : "."; +}; + +// Story file pattern - matches .stories.{js,jsx,ts,tsx,mdx} +export const STORY_FILE_REGEX = /\.stories\.(js|jsx|ts|tsx|mdx)$/; + +/** + * 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(getGlobBasePath))]; + + 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 story files + return !STORY_FILE_REGEX.test(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/tests/story-watcher.test.ts b/packages/ladle/tests/story-watcher.test.ts new file mode 100644 index 00000000..d90b4880 --- /dev/null +++ b/packages/ladle/tests/story-watcher.test.ts @@ -0,0 +1,328 @@ +import { test, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import { + getGlobBasePath, + STORY_FILE_REGEX, + createStoryWatcher, +} from "../lib/cli/story-watcher.js"; + +// ============================================ +// Unit tests for getGlobBasePath +// ============================================ + +test("getGlobBasePath extracts base path from simple glob", () => { + expect(getGlobBasePath("src/**/*.stories.tsx")).toBe("src"); +}); + +test("getGlobBasePath extracts nested base path", () => { + expect(getGlobBasePath("src/components/**/*.stories.tsx")).toBe( + "src/components", + ); +}); + +test("getGlobBasePath returns current dir for patterns starting with glob", () => { + expect(getGlobBasePath("**/*.stories.tsx")).toBe("."); +}); + +test("getGlobBasePath handles brace expansion patterns", () => { + expect(getGlobBasePath("src/{components,pages}/**/*.stories.tsx")).toBe( + "src", + ); +}); + +test("getGlobBasePath handles bracket character classes", () => { + expect(getGlobBasePath("src/[a-z]/**/*.stories.tsx")).toBe("src"); +}); + +test("getGlobBasePath returns full path when no glob characters", () => { + expect(getGlobBasePath("src/components/Button.stories.tsx")).toBe( + "src/components/Button.stories.tsx", + ); +}); + +test("getGlobBasePath handles single directory pattern", () => { + expect(getGlobBasePath("stories/*.stories.tsx")).toBe("stories"); +}); + +// ============================================ +// Unit tests for STORY_FILE_REGEX +// ============================================ + +test("STORY_FILE_REGEX matches .stories.tsx files", () => { + expect(STORY_FILE_REGEX.test("Button.stories.tsx")).toBe(true); +}); + +test("STORY_FILE_REGEX matches .stories.ts files", () => { + expect(STORY_FILE_REGEX.test("Button.stories.ts")).toBe(true); +}); + +test("STORY_FILE_REGEX matches .stories.jsx files", () => { + expect(STORY_FILE_REGEX.test("Button.stories.jsx")).toBe(true); +}); + +test("STORY_FILE_REGEX matches .stories.js files", () => { + expect(STORY_FILE_REGEX.test("Button.stories.js")).toBe(true); +}); + +test("STORY_FILE_REGEX matches .stories.mdx files", () => { + expect(STORY_FILE_REGEX.test("Button.stories.mdx")).toBe(true); +}); + +test("STORY_FILE_REGEX does not match regular tsx files", () => { + expect(STORY_FILE_REGEX.test("Button.tsx")).toBe(false); +}); + +test("STORY_FILE_REGEX does not match test files", () => { + expect(STORY_FILE_REGEX.test("Button.test.tsx")).toBe(false); +}); + +test("STORY_FILE_REGEX does not match files with stories in name but wrong extension", () => { + expect(STORY_FILE_REGEX.test("Button.stories.css")).toBe(false); +}); + +test("STORY_FILE_REGEX matches files with full path", () => { + expect(STORY_FILE_REGEX.test("src/components/Button.stories.tsx")).toBe(true); +}); + +// ============================================ +// 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( + 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(addedFiles[0]).toBe(storyPath); +}); + +test("createStoryWatcher ignores non-story files", async () => { + const watcher = createStoryWatcher( + 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( + 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(deletedFiles[0]).toBe(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( + 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(changedFiles[0]).toBe(storyPath); +}); + +test("createStoryWatcher handles multiple patterns (array)", async () => { + // Create additional directory + await fs.mkdir(path.join(tempDir, "components"), { recursive: true }); + + const watcher = createStoryWatcher([ + path.join(tempDir, "stories/**/*.stories.tsx"), + 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).toContain(storyPath1); + expect(addedFiles).toContain(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( + 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(addedFiles[0]).toBe(storyPath); +}); + +test("createStoryWatcher matches all story file extensions", async () => { + const watcher = createStoryWatcher(path.join(tempDir, "stories/**/*")); + 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(); + } +}); From ea93b2366f6455914f8b96c3b6396e3978564985 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Thu, 11 Dec 2025 15:56:25 +0000 Subject: [PATCH 2/4] Normalise path separators to fix Windows fails --- packages/ladle/lib/cli/story-watcher.js | 4 ++- packages/ladle/tests/story-watcher.test.ts | 33 +++++++++++++--------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/ladle/lib/cli/story-watcher.js b/packages/ladle/lib/cli/story-watcher.js index 6a70933e..46875b88 100644 --- a/packages/ladle/lib/cli/story-watcher.js +++ b/packages/ladle/lib/cli/story-watcher.js @@ -7,7 +7,9 @@ import chokidar from "chokidar"; * @returns {string} */ export const getGlobBasePath = (pattern) => { - const parts = pattern.split("/"); + // Normalise path separators to forward slashes for cross-platform compatibility + const normalised = pattern.replace(/\\/g, "/"); + const parts = normalised.split("/"); const baseParts = []; for (const part of parts) { if (part.includes("*") || part.includes("{") || part.includes("[")) break; diff --git a/packages/ladle/tests/story-watcher.test.ts b/packages/ladle/tests/story-watcher.test.ts index d90b4880..0a93e57d 100644 --- a/packages/ladle/tests/story-watcher.test.ts +++ b/packages/ladle/tests/story-watcher.test.ts @@ -8,6 +8,9 @@ import { createStoryWatcher, } from "../lib/cli/story-watcher.js"; +// Normalise path separators for cross-platform comparison +const normalisePath = (p: string) => p.replace(/\\/g, "/"); + // ============================================ // Unit tests for getGlobBasePath // ============================================ @@ -108,7 +111,7 @@ afterEach(async () => { test("createStoryWatcher detects new story file", async () => { const watcher = createStoryWatcher( - path.join(tempDir, "stories/**/*.stories.tsx"), + normalisePath(path.join(tempDir, "stories/**/*.stories.tsx")), ); const addedFiles: string[] = []; @@ -129,12 +132,12 @@ test("createStoryWatcher detects new story file", async () => { await watcher.close(); expect(addedFiles.length).toBe(1); - expect(addedFiles[0]).toBe(storyPath); + expect(normalisePath(addedFiles[0])).toBe(normalisePath(storyPath)); }); test("createStoryWatcher ignores non-story files", async () => { const watcher = createStoryWatcher( - path.join(tempDir, "stories/**/*.stories.tsx"), + normalisePath(path.join(tempDir, "stories/**/*.stories.tsx")), ); const addedFiles: string[] = []; @@ -163,7 +166,7 @@ test("createStoryWatcher detects story file deletion", async () => { await fs.writeFile(storyPath, 'export const Button = () => "Hello";'); const watcher = createStoryWatcher( - path.join(tempDir, "stories/**/*.stories.tsx"), + normalisePath(path.join(tempDir, "stories/**/*.stories.tsx")), ); const deletedFiles: string[] = []; @@ -183,7 +186,7 @@ test("createStoryWatcher detects story file deletion", async () => { await watcher.close(); expect(deletedFiles.length).toBe(1); - expect(deletedFiles[0]).toBe(storyPath); + expect(normalisePath(deletedFiles[0])).toBe(normalisePath(storyPath)); }); test("createStoryWatcher detects story file changes", async () => { @@ -192,7 +195,7 @@ test("createStoryWatcher detects story file changes", async () => { await fs.writeFile(storyPath, 'export const Button = () => "Hello";'); const watcher = createStoryWatcher( - path.join(tempDir, "stories/**/*.stories.tsx"), + normalisePath(path.join(tempDir, "stories/**/*.stories.tsx")), ); const changedFiles: string[] = []; @@ -212,7 +215,7 @@ test("createStoryWatcher detects story file changes", async () => { await watcher.close(); expect(changedFiles.length).toBe(1); - expect(changedFiles[0]).toBe(storyPath); + expect(normalisePath(changedFiles[0])).toBe(normalisePath(storyPath)); }); test("createStoryWatcher handles multiple patterns (array)", async () => { @@ -220,8 +223,8 @@ test("createStoryWatcher handles multiple patterns (array)", async () => { await fs.mkdir(path.join(tempDir, "components"), { recursive: true }); const watcher = createStoryWatcher([ - path.join(tempDir, "stories/**/*.stories.tsx"), - path.join(tempDir, "components/**/*.stories.tsx"), + normalisePath(path.join(tempDir, "stories/**/*.stories.tsx")), + normalisePath(path.join(tempDir, "components/**/*.stories.tsx")), ]); const addedFiles: string[] = []; @@ -244,8 +247,8 @@ test("createStoryWatcher handles multiple patterns (array)", async () => { await watcher.close(); expect(addedFiles.length).toBe(2); - expect(addedFiles).toContain(storyPath1); - expect(addedFiles).toContain(storyPath2); + expect(addedFiles.map(normalisePath)).toContain(normalisePath(storyPath1)); + expect(addedFiles.map(normalisePath)).toContain(normalisePath(storyPath2)); }); test("createStoryWatcher detects stories in nested directories", async () => { @@ -255,7 +258,7 @@ test("createStoryWatcher detects stories in nested directories", async () => { }); const watcher = createStoryWatcher( - path.join(tempDir, "stories/**/*.stories.tsx"), + normalisePath(path.join(tempDir, "stories/**/*.stories.tsx")), ); const addedFiles: string[] = []; @@ -282,11 +285,13 @@ test("createStoryWatcher detects stories in nested directories", async () => { await watcher.close(); expect(addedFiles.length).toBe(1); - expect(addedFiles[0]).toBe(storyPath); + expect(normalisePath(addedFiles[0])).toBe(normalisePath(storyPath)); }); test("createStoryWatcher matches all story file extensions", async () => { - const watcher = createStoryWatcher(path.join(tempDir, "stories/**/*")); + const watcher = createStoryWatcher( + normalisePath(path.join(tempDir, "stories/**/*")), + ); const addedFiles: string[] = []; watcher.on("add", (filePath) => { From 35f74b86b73b81223aae7b366e943e9bc7a522b9 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Mon, 15 Dec 2025 10:45:39 +0000 Subject: [PATCH 3/4] Remove duplicated STORY_FILE_REGEX + use picomatch.scan().base for basepath --- packages/ladle/lib/cli/story-watcher.js | 31 ++------ packages/ladle/package.json | 2 + packages/ladle/tests/story-watcher.test.ts | 89 ++-------------------- pnpm-lock.yaml | 11 +++ 4 files changed, 25 insertions(+), 108 deletions(-) diff --git a/packages/ladle/lib/cli/story-watcher.js b/packages/ladle/lib/cli/story-watcher.js index 46875b88..2b9be0b9 100644 --- a/packages/ladle/lib/cli/story-watcher.js +++ b/packages/ladle/lib/cli/story-watcher.js @@ -1,25 +1,5 @@ import chokidar from "chokidar"; - -/** - * Extract the base directory from a glob pattern. - * e.g., "src/**\/*.stories.tsx" -> "src" - * @param {string} pattern - * @returns {string} - */ -export const getGlobBasePath = (pattern) => { - // Normalise path separators to forward slashes for cross-platform compatibility - const normalised = pattern.replace(/\\/g, "/"); - const parts = normalised.split("/"); - const baseParts = []; - for (const part of parts) { - if (part.includes("*") || part.includes("{") || part.includes("[")) break; - baseParts.push(part); - } - return baseParts.length > 0 ? baseParts.join("/") : "."; -}; - -// Story file pattern - matches .stories.{js,jsx,ts,tsx,mdx} -export const STORY_FILE_REGEX = /\.stories\.(js|jsx|ts|tsx|mdx)$/; +import picomatch from "picomatch"; /** * Creates a chokidar watcher configured to watch story files. @@ -31,7 +11,10 @@ export const createStoryWatcher = (storyPatterns, options = {}) => { const patterns = Array.isArray(storyPatterns) ? storyPatterns : [storyPatterns]; - const baseDirs = [...new Set(patterns.map(getGlobBasePath))]; + const baseDirs = [ + ...new Set(patterns.map((p) => picomatch.scan(p).base || ".")), + ]; + const isMatch = picomatch(patterns); return chokidar.watch(baseDirs, { persistent: true, @@ -40,8 +23,8 @@ export const createStoryWatcher = (storyPatterns, options = {}) => { // 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 story files - return !STORY_FILE_REGEX.test(filePath); + // Only watch files matching the user's configured story patterns + return !isMatch(filePath); }, ...options, }); 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 index 0a93e57d..8668ebd6 100644 --- a/packages/ladle/tests/story-watcher.test.ts +++ b/packages/ladle/tests/story-watcher.test.ts @@ -2,93 +2,11 @@ import { test, expect, beforeEach, afterEach } from "vitest"; import fs from "fs/promises"; import path from "path"; import os from "os"; -import { - getGlobBasePath, - STORY_FILE_REGEX, - createStoryWatcher, -} from "../lib/cli/story-watcher.js"; +import { createStoryWatcher } from "../lib/cli/story-watcher.js"; // Normalise path separators for cross-platform comparison const normalisePath = (p: string) => p.replace(/\\/g, "/"); -// ============================================ -// Unit tests for getGlobBasePath -// ============================================ - -test("getGlobBasePath extracts base path from simple glob", () => { - expect(getGlobBasePath("src/**/*.stories.tsx")).toBe("src"); -}); - -test("getGlobBasePath extracts nested base path", () => { - expect(getGlobBasePath("src/components/**/*.stories.tsx")).toBe( - "src/components", - ); -}); - -test("getGlobBasePath returns current dir for patterns starting with glob", () => { - expect(getGlobBasePath("**/*.stories.tsx")).toBe("."); -}); - -test("getGlobBasePath handles brace expansion patterns", () => { - expect(getGlobBasePath("src/{components,pages}/**/*.stories.tsx")).toBe( - "src", - ); -}); - -test("getGlobBasePath handles bracket character classes", () => { - expect(getGlobBasePath("src/[a-z]/**/*.stories.tsx")).toBe("src"); -}); - -test("getGlobBasePath returns full path when no glob characters", () => { - expect(getGlobBasePath("src/components/Button.stories.tsx")).toBe( - "src/components/Button.stories.tsx", - ); -}); - -test("getGlobBasePath handles single directory pattern", () => { - expect(getGlobBasePath("stories/*.stories.tsx")).toBe("stories"); -}); - -// ============================================ -// Unit tests for STORY_FILE_REGEX -// ============================================ - -test("STORY_FILE_REGEX matches .stories.tsx files", () => { - expect(STORY_FILE_REGEX.test("Button.stories.tsx")).toBe(true); -}); - -test("STORY_FILE_REGEX matches .stories.ts files", () => { - expect(STORY_FILE_REGEX.test("Button.stories.ts")).toBe(true); -}); - -test("STORY_FILE_REGEX matches .stories.jsx files", () => { - expect(STORY_FILE_REGEX.test("Button.stories.jsx")).toBe(true); -}); - -test("STORY_FILE_REGEX matches .stories.js files", () => { - expect(STORY_FILE_REGEX.test("Button.stories.js")).toBe(true); -}); - -test("STORY_FILE_REGEX matches .stories.mdx files", () => { - expect(STORY_FILE_REGEX.test("Button.stories.mdx")).toBe(true); -}); - -test("STORY_FILE_REGEX does not match regular tsx files", () => { - expect(STORY_FILE_REGEX.test("Button.tsx")).toBe(false); -}); - -test("STORY_FILE_REGEX does not match test files", () => { - expect(STORY_FILE_REGEX.test("Button.test.tsx")).toBe(false); -}); - -test("STORY_FILE_REGEX does not match files with stories in name but wrong extension", () => { - expect(STORY_FILE_REGEX.test("Button.stories.css")).toBe(false); -}); - -test("STORY_FILE_REGEX matches files with full path", () => { - expect(STORY_FILE_REGEX.test("src/components/Button.stories.tsx")).toBe(true); -}); - // ============================================ // Integration tests for createStoryWatcher // ============================================ @@ -289,8 +207,11 @@ test("createStoryWatcher detects stories in nested directories", async () => { }); 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/**/*")), + normalisePath( + path.join(tempDir, "stories/**/*.stories.{js,jsx,ts,tsx,mdx}"), + ), ); const addedFiles: string[] = []; 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': {} From 457c39a895370d89f66c3df2f5093631dc8d3e20 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Mon, 15 Dec 2025 10:47:14 +0000 Subject: [PATCH 4/4] Update changelog to reflect changes --- .changeset/fix-story-watching-chokidar-v4.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/fix-story-watching-chokidar-v4.md b/.changeset/fix-story-watching-chokidar-v4.md index 873fff2c..62a31708 100644 --- a/.changeset/fix-story-watching-chokidar-v4.md +++ b/.changeset/fix-story-watching-chokidar-v4.md @@ -4,10 +4,10 @@ Fix story file watching for add/remove detection with chokidar v4 -Chokidar v4 no longer supports glob patterns directly, so the watcher now: +Chokidar v4 no longer supports glob patterns directly, so the watcher now uses picomatch to: -- Extracts base directories from glob patterns using `getGlobBasePath` -- Filters story files using `STORY_FILE_REGEX` in the `ignored` callback -- Properly handles the case where `stats` is undefined during initial directory checks +- 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.