Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Open
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
13 changes: 13 additions & 0 deletions .changeset/fix-story-watching-chokidar-v4.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions packages/ladle/lib/cli/story-watcher.js
Original file line number Diff line number Diff line change
@@ -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,
});
};
7 changes: 2 additions & 5 deletions packages/ladle/lib/cli/vite-dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions packages/ladle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
254 changes: 254 additions & 0 deletions packages/ladle/tests/story-watcher.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((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<void>((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<void>((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<void>((resolve) => watcher.on("ready", resolve));

// Delete the story file
await fs.unlink(storyPath);

// Wait for the watcher to detect the deletion
await new Promise<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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();
}
});
11 changes: 11 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading