From ceaa3aa213a913f1091b32280f352eb62b02cf74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20Karakanl=C4=B1?= <102619516+actuallyzefe@users.noreply.github.com> Date: Sat, 25 Apr 2026 18:40:45 +0300 Subject: [PATCH 01/12] fix(cli): add --watch option for dynamic file watching in dev server Introduced a new --watch option to the CLI for specifying additional file or directory paths to monitor. Changes in these paths will trigger a reload of all email previews, enhancing the development experience for files loaded at runtime. Updated the setupHotreloading function to handle these paths and added utility functions for path management. --- packages/react-email/src/cli/commands/dev.ts | 22 ++++- packages/react-email/src/cli/index.ts | 5 ++ .../hot-reloading/extra-watch-paths.ts | 20 +++++ .../hot-reloading/setup-hot-reloading.spec.ts | 84 +++++++++++++++++++ .../hot-reloading/setup-hot-reloading.ts | 39 +++++++++ 5 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 packages/react-email/src/cli/utils/preview/hot-reloading/extra-watch-paths.ts create mode 100644 packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.spec.ts diff --git a/packages/react-email/src/cli/commands/dev.ts b/packages/react-email/src/cli/commands/dev.ts index dac1a871cb..dd5ea018af 100644 --- a/packages/react-email/src/cli/commands/dev.ts +++ b/packages/react-email/src/cli/commands/dev.ts @@ -1,25 +1,43 @@ import fs from 'node:fs'; +import path from 'node:path'; import { setupHotreloading, startDevServer } from '../utils/index.js'; interface Args { dir: string; port: string; + watch: string[]; } -export const dev = async ({ dir: emailsDirRelativePath, port }: Args) => { +export const dev = async ({ + dir: emailsDirRelativePath, + port, + watch, +}: Args) => { try { if (!fs.existsSync(emailsDirRelativePath)) { console.error(`Missing ${emailsDirRelativePath} folder`); process.exit(1); } + const extraWatchPaths: string[] = []; + for (const watchPath of watch) { + const absolutePath = path.resolve(process.cwd(), watchPath); + if (!fs.existsSync(absolutePath)) { + console.warn( + `Skipping --watch path "${watchPath}" because it does not exist`, + ); + continue; + } + extraWatchPaths.push(absolutePath); + } + const devServer = await startDevServer( emailsDirRelativePath, emailsDirRelativePath, // defaults to ./emails/static for the static files that are served to the preview Number.parseInt(port, 10), ); - await setupHotreloading(devServer, emailsDirRelativePath); + await setupHotreloading(devServer, emailsDirRelativePath, extraWatchPaths); } catch (error) { console.log(error); process.exit(1); diff --git a/packages/react-email/src/cli/index.ts b/packages/react-email/src/cli/index.ts index 93af0c1e51..e8ef776e23 100644 --- a/packages/react-email/src/cli/index.ts +++ b/packages/react-email/src/cli/index.ts @@ -50,6 +50,11 @@ if (!hasRequiredFlags) { './emails', ) .option('-p --port ', 'Port to run dev server on', '3000') + .option( + '-w, --watch ', + 'Additional file or directory paths to watch. Changes inside these paths will reload all email previews. Useful for files loaded at runtime (e.g. i18n message JSON) that are not statically imported.', + [], + ) .action(dev); program diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/extra-watch-paths.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/extra-watch-paths.ts new file mode 100644 index 0000000000..0ddc3bab73 --- /dev/null +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/extra-watch-paths.ts @@ -0,0 +1,20 @@ +import path from 'node:path'; +import type { EmailsDirectory } from '../../get-emails-directory-metadata.js'; + +export const isUnderAnyPath = (absolutePath: string, roots: string[]) => + roots.some( + (root) => + absolutePath === root || absolutePath.startsWith(root + path.sep), + ); + +export const collectEmailTemplatePaths = ( + directory: EmailsDirectory, +): string[] => { + const paths = directory.emailFilenames.map((filename) => + path.join(directory.absolutePath, filename), + ); + for (const subDirectory of directory.subDirectories) { + paths.push(...collectEmailTemplatePaths(subDirectory)); + } + return paths; +}; diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.spec.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.spec.ts new file mode 100644 index 0000000000..17c495b3a6 --- /dev/null +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.spec.ts @@ -0,0 +1,84 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { EmailsDirectory } from '../../get-emails-directory-metadata.js'; +import { + collectEmailTemplatePaths, + isUnderAnyPath, +} from './extra-watch-paths.js'; + +describe('isUnderAnyPath()', () => { + const roots = [path.resolve('/proj/i18n'), path.resolve('/proj/locales')]; + + it('matches an exact root', () => { + expect(isUnderAnyPath(path.resolve('/proj/i18n'), roots)).toBe(true); + }); + + it('matches a nested file under a root', () => { + expect( + isUnderAnyPath(path.resolve('/proj/i18n/messages/en.json'), roots), + ).toBe(true); + }); + + it('does not match a sibling whose name shares a prefix', () => { + expect(isUnderAnyPath(path.resolve('/proj/i18n-extra/file'), roots)).toBe( + false, + ); + }); + + it('does not match an unrelated path', () => { + expect(isUnderAnyPath(path.resolve('/proj/emails/welcome.tsx'), roots)).toBe( + false, + ); + }); + + it('returns false when no roots are configured', () => { + expect(isUnderAnyPath(path.resolve('/proj/i18n/x.json'), [])).toBe(false); + }); +}); + +describe('collectEmailTemplatePaths()', () => { + it('flattens templates from the root and nested subdirectories', () => { + const root: EmailsDirectory = { + absolutePath: '/proj/emails', + relativePath: '', + directoryName: 'emails', + emailFilenames: ['welcome.tsx', 'goodbye.tsx'], + subDirectories: [ + { + absolutePath: '/proj/emails/marketing', + relativePath: 'marketing', + directoryName: 'marketing', + emailFilenames: ['promo.tsx'], + subDirectories: [ + { + absolutePath: '/proj/emails/marketing/seasonal', + relativePath: 'marketing/seasonal', + directoryName: 'seasonal', + emailFilenames: ['holiday.tsx'], + subDirectories: [], + }, + ], + }, + ], + }; + + expect(collectEmailTemplatePaths(root)).toEqual([ + path.join('/proj/emails', 'welcome.tsx'), + path.join('/proj/emails', 'goodbye.tsx'), + path.join('/proj/emails/marketing', 'promo.tsx'), + path.join('/proj/emails/marketing/seasonal', 'holiday.tsx'), + ]); + }); + + it('returns an empty list for an empty directory', () => { + expect( + collectEmailTemplatePaths({ + absolutePath: '/proj/emails', + relativePath: '', + directoryName: 'emails', + emailFilenames: [], + subDirectories: [], + }), + ).toEqual([]); + }); +}); diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.ts index d6fb95e2bc..ca040c6417 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.ts @@ -3,12 +3,18 @@ import path from 'node:path'; import { watch } from 'chokidar'; import debounce from 'debounce'; import { type Socket, Server as SocketServer } from 'socket.io'; +import { getEmailsDirectoryMetadata } from '../../get-emails-directory-metadata.js'; import type { HotReloadChange } from '../../types/hot-reload-change.js'; import { createDependencyGraph } from './create-dependency-graph.js'; +import { + collectEmailTemplatePaths, + isUnderAnyPath, +} from './extra-watch-paths.js'; export const setupHotreloading = async ( devServer: http.Server, emailDirRelativePath: string, + extraWatchPaths: string[] = [], ) => { let clients: Socket[] = []; const io = new SocketServer(devServer); @@ -47,6 +53,13 @@ export const setupHotreloading = async ( emailDirRelativePath, ); + const resolvedExtraWatchPaths = extraWatchPaths.map((p) => + path.resolve(process.cwd(), p), + ); + + const isUnderExtraWatchPath = (absolutePath: string) => + isUnderAnyPath(absolutePath, resolvedExtraWatchPaths); + const [dependencyGraph, updateDependencyGraph, { resolveDependentsOf }] = await createDependencyGraph(absolutePathToEmailsDirectory); @@ -66,6 +79,10 @@ export const setupHotreloading = async ( watcher.add(p); } + if (resolvedExtraWatchPaths.length > 0) { + watcher.add(resolvedExtraWatchPaths); + } + const exit = async () => { await watcher.close(); }; @@ -114,6 +131,28 @@ export const setupHotreloading = async ( filename: path.relative(absolutePathToEmailsDirectory, dependentPath), }); } + + // Files in --watch paths are loaded at runtime (e.g. i18n message JSON read + // through a backend) and never enter the static dependency graph, so we + // can't tell which template depends on them. Reload every template instead. + if (isUnderExtraWatchPath(pathToChangeTarget)) { + const metadata = await getEmailsDirectoryMetadata( + absolutePathToEmailsDirectory, + true, + ); + if (metadata) { + for (const templatePath of collectEmailTemplatePaths(metadata)) { + changes.push({ + event: 'change' as const, + filename: path.relative( + absolutePathToEmailsDirectory, + templatePath, + ), + }); + } + } + } + reload(); }); From 06408a43ede2e2d9010ba564470588c6b512a3ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20Karakanl=C4=B1?= <102619516+actuallyzefe@users.noreply.github.com> Date: Sat, 25 Apr 2026 18:56:42 +0300 Subject: [PATCH 02/12] docs(readme): update documentation for --watch option in react-email CLI --- packages/react-email/readme.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/react-email/readme.md b/packages/react-email/readme.md index fddaf4d820..9de0191554 100644 --- a/packages/react-email/readme.md +++ b/packages/react-email/readme.md @@ -30,6 +30,33 @@ Starts a local development server that will watch your files and automatically r npx react-email dev ``` +#### Watching files outside the emails directory + +By default, only the emails directory and files reachable through static imports from your templates are watched. Files loaded at runtime are invisible to the static dependency graph, so editing them does not refresh the preview. Common examples: + +- `react-i18next` message JSON pulled in through a dynamic import: + + ```ts + i18next.use(resourcesToBackend((lng, ns) => import(`./messages/${lng}/${ns}.json`))); + ``` + +- MDX or YAML content loaded by a backend at request time. +- Any file read with `fs.readFileSync` / `fs.promises.readFile`. + +Pass `-w, --watch ` to declare extra files or directories to watch. A change in any of these paths reloads every email preview: + +```sh +npx react-email dev -d ./emails -w ./i18n +``` + +Multiple paths are supported: + +```sh +npx react-email dev -w ./i18n ./content +``` + +Non-existent paths are skipped with a warning rather than failing. + ### `email export` Generates the plain HTML files of your emails into a `out` directory. From 4bd5cb5d661425871a17ac96cbe99f6803add719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20Karakanl=C4=B1?= <102619516+actuallyzefe@users.noreply.github.com> Date: Sat, 25 Apr 2026 19:02:02 +0300 Subject: [PATCH 03/12] chore: lint fix --- .../cli/utils/preview/hot-reloading/extra-watch-paths.ts | 3 +-- .../utils/preview/hot-reloading/setup-hot-reloading.spec.ts | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/extra-watch-paths.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/extra-watch-paths.ts index 0ddc3bab73..7d488f814d 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/extra-watch-paths.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/extra-watch-paths.ts @@ -3,8 +3,7 @@ import type { EmailsDirectory } from '../../get-emails-directory-metadata.js'; export const isUnderAnyPath = (absolutePath: string, roots: string[]) => roots.some( - (root) => - absolutePath === root || absolutePath.startsWith(root + path.sep), + (root) => absolutePath === root || absolutePath.startsWith(root + path.sep), ); export const collectEmailTemplatePaths = ( diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.spec.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.spec.ts index 17c495b3a6..6987764b6b 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.spec.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.spec.ts @@ -26,9 +26,9 @@ describe('isUnderAnyPath()', () => { }); it('does not match an unrelated path', () => { - expect(isUnderAnyPath(path.resolve('/proj/emails/welcome.tsx'), roots)).toBe( - false, - ); + expect( + isUnderAnyPath(path.resolve('/proj/emails/welcome.tsx'), roots), + ).toBe(false); }); it('returns false when no roots are configured', () => { From b8b3cde882db69e90794151db35691dd5ce34dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20Karakanl=C4=B1?= <102619516+actuallyzefe@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:23:58 +0300 Subject: [PATCH 04/12] refactor(cli): remove --watch option and enhance dynamic import handling This commit removes the `--watch` option from the CLI, simplifying the development server setup. It enhances the dynamic import handling by introducing a mechanism to track directories from dynamic imports, ensuring that changes in these directories trigger email previews to reload. Additionally, it updates the dependency graph to accommodate these changes and includes tests for the new functionality. --- packages/react-email/readme.md | 27 ----- packages/react-email/src/cli/commands/dev.ts | 22 +--- packages/react-email/src/cli/index.ts | 5 - .../create-dependency-graph.spec.ts | 2 + .../hot-reloading/create-dependency-graph.ts | 114 +++++++++++++++++- .../dynamic-import-graph.spec.ts | 31 +++++ .../hot-reloading/extra-watch-paths.ts | 19 --- .../get-imported-modules.spec.ts | 110 +++++++++++------ .../hot-reloading/get-imported-modules.ts | 48 ++++++-- .../hot-reloading/setup-hot-reloading.spec.ts | 84 ------------- .../hot-reloading/setup-hot-reloading.ts | 64 ++++------ .../messages/en/common.json | 1 + .../test/dynamic-import-graph/template.ts | 4 + 13 files changed, 292 insertions(+), 239 deletions(-) create mode 100644 packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts delete mode 100644 packages/react-email/src/cli/utils/preview/hot-reloading/extra-watch-paths.ts delete mode 100644 packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.spec.ts create mode 100644 packages/react-email/src/cli/utils/preview/hot-reloading/test/dynamic-import-graph/messages/en/common.json create mode 100644 packages/react-email/src/cli/utils/preview/hot-reloading/test/dynamic-import-graph/template.ts diff --git a/packages/react-email/readme.md b/packages/react-email/readme.md index 9de0191554..fddaf4d820 100644 --- a/packages/react-email/readme.md +++ b/packages/react-email/readme.md @@ -30,33 +30,6 @@ Starts a local development server that will watch your files and automatically r npx react-email dev ``` -#### Watching files outside the emails directory - -By default, only the emails directory and files reachable through static imports from your templates are watched. Files loaded at runtime are invisible to the static dependency graph, so editing them does not refresh the preview. Common examples: - -- `react-i18next` message JSON pulled in through a dynamic import: - - ```ts - i18next.use(resourcesToBackend((lng, ns) => import(`./messages/${lng}/${ns}.json`))); - ``` - -- MDX or YAML content loaded by a backend at request time. -- Any file read with `fs.readFileSync` / `fs.promises.readFile`. - -Pass `-w, --watch ` to declare extra files or directories to watch. A change in any of these paths reloads every email preview: - -```sh -npx react-email dev -d ./emails -w ./i18n -``` - -Multiple paths are supported: - -```sh -npx react-email dev -w ./i18n ./content -``` - -Non-existent paths are skipped with a warning rather than failing. - ### `email export` Generates the plain HTML files of your emails into a `out` directory. diff --git a/packages/react-email/src/cli/commands/dev.ts b/packages/react-email/src/cli/commands/dev.ts index dd5ea018af..dac1a871cb 100644 --- a/packages/react-email/src/cli/commands/dev.ts +++ b/packages/react-email/src/cli/commands/dev.ts @@ -1,43 +1,25 @@ import fs from 'node:fs'; -import path from 'node:path'; import { setupHotreloading, startDevServer } from '../utils/index.js'; interface Args { dir: string; port: string; - watch: string[]; } -export const dev = async ({ - dir: emailsDirRelativePath, - port, - watch, -}: Args) => { +export const dev = async ({ dir: emailsDirRelativePath, port }: Args) => { try { if (!fs.existsSync(emailsDirRelativePath)) { console.error(`Missing ${emailsDirRelativePath} folder`); process.exit(1); } - const extraWatchPaths: string[] = []; - for (const watchPath of watch) { - const absolutePath = path.resolve(process.cwd(), watchPath); - if (!fs.existsSync(absolutePath)) { - console.warn( - `Skipping --watch path "${watchPath}" because it does not exist`, - ); - continue; - } - extraWatchPaths.push(absolutePath); - } - const devServer = await startDevServer( emailsDirRelativePath, emailsDirRelativePath, // defaults to ./emails/static for the static files that are served to the preview Number.parseInt(port, 10), ); - await setupHotreloading(devServer, emailsDirRelativePath, extraWatchPaths); + await setupHotreloading(devServer, emailsDirRelativePath); } catch (error) { console.log(error); process.exit(1); diff --git a/packages/react-email/src/cli/index.ts b/packages/react-email/src/cli/index.ts index e8ef776e23..93af0c1e51 100644 --- a/packages/react-email/src/cli/index.ts +++ b/packages/react-email/src/cli/index.ts @@ -50,11 +50,6 @@ if (!hasRequiredFlags) { './emails', ) .option('-p --port ', 'Port to run dev server on', '3000') - .option( - '-w, --watch ', - 'Additional file or directory paths to watch. Changes inside these paths will reload all email previews. Useful for files loaded at runtime (e.g. i18n message JSON) that are not statically imported.', - [], - ) .action(dev); program diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.spec.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.spec.ts index 54e55d623c..0212b2dc86 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.spec.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.spec.ts @@ -162,6 +162,7 @@ import {} from './general-importing-file'; toAbsolute('file-b.ts'), toAbsolute('general-importing-file.ts'), ], + globDependencyPaths: [], moduleDependencies: [], } satisfies DependencyGraph[number]); expect(dependencyGraph[toAbsolute('file-a.ts')]?.dependentPaths).toContain( @@ -192,6 +193,7 @@ import {} from './file-b'; path: pathToTemporaryFile, dependentPaths: [], dependencyPaths: [toAbsolute('file-a.ts'), toAbsolute('file-b.ts')], + globDependencyPaths: [], moduleDependencies: [], } satisfies DependencyGraph[number]); expect(dependencyGraph[toAbsolute('file-a.ts')]?.dependentPaths).toContain( diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.ts index d61b885bdc..157f2a2ffd 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.ts @@ -10,6 +10,13 @@ interface Module { dependencyPaths: string[]; dependentPaths: string[]; + /** + * Absolute directory paths discovered from dynamic `import(\`./prefix/${expr}\`)` + * call sites. Any file change inside one of these directories should be + * treated as a change to this module. + */ + globDependencyPaths: string[]; + moduleDependencies: string[]; } @@ -70,6 +77,48 @@ const checkFileExtensionsUntilItExists = ( } }; +const isUnderDirectory = (filePath: string, directoryPath: string) => + filePath === directoryPath || filePath.startsWith(directoryPath + path.sep); + +/** + * Resolves the leading static prefix of a dynamic `import()` template literal + * to an absolute directory path on disk. + * + * Returns `undefined` when the prefix is too generic (e.g. `./` or `../`) to + * be useful — watching the entire module directory would over-trigger reloads. + */ +const resolveGlobPrefixToDirectory = ( + prefix: string, + modulePath: string, +): string | undefined => { + // Bail on bare module specifiers (e.g. `import(\`some-pkg/${name}\`)`). + const isRelative = prefix.startsWith('.') || path.isAbsolute(prefix); + if (!isRelative) return undefined; + + const moduleDirectory = path.dirname(modulePath); + const resolvedPrefix = path.resolve(moduleDirectory, prefix); + + // Pick the directory portion of the prefix. If the prefix doesn't end in a + // separator, the last segment is treated as a partial filename and dropped. + const directory = + prefix.endsWith('/') || prefix.endsWith(path.sep) + ? resolvedPrefix + : path.dirname(resolvedPrefix); + + // Don't watch the module's own directory or any of its ancestors — that + // would either be redundant or far too broad. + if (isUnderDirectory(moduleDirectory, directory)) return undefined; + + if (!existsSync(directory)) return undefined; + try { + if (!statSync(directory).isDirectory()) return undefined; + } catch (_) { + return undefined; + } + + return directory; +}; + /** * Creates a stateful dependency graph that is structured in a way that you can get * the dependents of a module from its path. @@ -88,6 +137,7 @@ export const createDependencyGraph = async (directory: string) => { path, dependencyPaths: [], dependentPaths: [], + globDependencyPaths: [], moduleDependencies: [], }, ]), @@ -95,8 +145,11 @@ export const createDependencyGraph = async (directory: string) => { const getDependencyPaths = async (filePath: string) => { const contents = await fs.readFile(filePath, 'utf8'); + const imports = isJavascriptModule(filePath) + ? getImportedModules(contents) + : { staticImports: [], dynamicGlobPrefixes: [] }; const importedPaths = isJavascriptModule(filePath) - ? resolvePathAliases(getImportedModules(contents), path.dirname(filePath)) + ? resolvePathAliases(imports.staticImports, path.dirname(filePath)) : []; const importedPathsRelativeToDirectory = importedPaths.map( (dependencyPath) => { @@ -115,7 +168,7 @@ export const createDependencyGraph = async (directory: string) => { /* path.resolve resolves paths differently from what imports on javascript do. - So if we wouldn't do this, for an email at "/path/to/email.tsx" with a dependency path of "./other-email" + So if we wouldn't do this, for an email at "/path/to/email.tsx" with a dependency path of "./other-email" would end up going into /path/to/email.tsx/other-email instead of /path/to/other-email which is the one the import is meant to go to */ @@ -183,9 +236,18 @@ export const createDependencyGraph = async (directory: string) => { dependencyPath.startsWith('.') || path.isAbsolute(dependencyPath), ); + const globDependencyPaths = Array.from( + new Set( + imports.dynamicGlobPrefixes + .map((prefix) => resolveGlobPrefixToDirectory(prefix, filePath)) + .filter((d): d is string => typeof d === 'string'), + ), + ); + return { dependencyPaths: nonNodeModuleImportPathsRelativeToDirectory, moduleDependencies, + globDependencyPaths, }; }; @@ -195,14 +257,19 @@ export const createDependencyGraph = async (directory: string) => { path: moduleFilePath, dependencyPaths: [], dependentPaths: [], + globDependencyPaths: [], moduleDependencies: [], }; } - const { moduleDependencies, dependencyPaths: newDependencyPaths } = - await getDependencyPaths(moduleFilePath); + const { + moduleDependencies, + dependencyPaths: newDependencyPaths, + globDependencyPaths: newGlobDependencyPaths, + } = await getDependencyPaths(moduleFilePath); graph[moduleFilePath].moduleDependencies = moduleDependencies; + graph[moduleFilePath].globDependencyPaths = newGlobDependencyPaths; // we go through these to remove the ones that don't exist anymore for (const dependencyPath of graph[moduleFilePath].dependencyPaths) { @@ -264,6 +331,21 @@ export const createDependencyGraph = async (directory: string) => { } }; + /** + * Returns the union of all directories any module declared as a glob + * dependency. Useful for telling chokidar what to watch on top of the static + * dependency graph. + */ + const getGlobDependencyDirectories = (): string[] => { + const directories = new Set(); + for (const module of Object.values(graph)) { + for (const directory of module.globDependencyPaths) { + directories.add(directory); + } + } + return [...directories]; + }; + return [ graph, async (event: EventName, pathToModified: string) => { @@ -309,6 +391,11 @@ export const createDependencyGraph = async (directory: string) => { /** * Resolves all modules that depend on the specified module, directly or indirectly. * + * If the path doesn't correspond to a graph node (e.g. a JSON file + * loaded via dynamic `import(\`...\`)`), modules whose glob directories + * contain the path are treated as direct dependents and their own + * dependents are resolved transitively. + * * @param pathToModule - The path to the module whose dependents we want to find * @returns An array of paths to all modules that depend on the specified module */ @@ -318,6 +405,23 @@ export const createDependencyGraph = async (directory: string) => { const dependentPaths = new Set(); const stack: string[] = [pathToModule]; + // Seed the stack with modules that declared a glob covering this path. + // We do this even when the path corresponds to a known node, so e.g. + // a JSON file that's both statically and dynamically imported reloads + // through both routes. + for (const module of Object.values(graph)) { + for (const globDirectory of module.globDependencyPaths) { + if ( + isUnderDirectory(pathToModule, globDirectory) && + module.path !== pathToModule && + !dependentPaths.has(module.path) + ) { + dependentPaths.add(module.path); + stack.push(module.path); + } + } + } + while (stack.length > 0) { const currentPath = stack.pop()!; const moduleEntry = graph[currentPath]; @@ -338,6 +442,8 @@ export const createDependencyGraph = async (directory: string) => { return [...dependentPaths.values()]; }, + + getGlobDependencyDirectories, }, ] as const; }; diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts new file mode 100644 index 0000000000..065ac2a44c --- /dev/null +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts @@ -0,0 +1,31 @@ +import path from 'node:path'; +import { createDependencyGraph } from './create-dependency-graph.js'; + +vi.mock('@babel/traverse', async () => { + const traverse = await vi.importActual('@babel/traverse'); + return { default: traverse }; +}); + +const fixtureDirectory = path.join( + import.meta.dirname, + './test/dynamic-import-graph', +); + +describe('createDependencyGraph() with dynamic imports', () => { + it('exposes the directory of a template-literal `import()` as a glob dependency and resolves runtime files to the importing module', async () => { + const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = + await createDependencyGraph(fixtureDirectory); + + const messagesDirectory = path.join(fixtureDirectory, 'messages'); + const templatePath = path.join(fixtureDirectory, 'template.ts'); + + expect(getGlobDependencyDirectories()).toEqual([messagesDirectory]); + + // A JSON file matched by the dynamic import has no graph node, but the + // importing module should still be reported as a dependent so the preview + // reloads when it changes. + expect( + resolveDependentsOf(path.join(messagesDirectory, 'en', 'common.json')), + ).toEqual([templatePath]); + }); +}); diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/extra-watch-paths.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/extra-watch-paths.ts deleted file mode 100644 index 7d488f814d..0000000000 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/extra-watch-paths.ts +++ /dev/null @@ -1,19 +0,0 @@ -import path from 'node:path'; -import type { EmailsDirectory } from '../../get-emails-directory-metadata.js'; - -export const isUnderAnyPath = (absolutePath: string, roots: string[]) => - roots.some( - (root) => absolutePath === root || absolutePath.startsWith(root + path.sep), - ); - -export const collectEmailTemplatePaths = ( - directory: EmailsDirectory, -): string[] => { - const paths = directory.emailFilenames.map((filename) => - path.join(directory.absolutePath, filename), - ); - for (const subDirectory of directory.subDirectories) { - paths.push(...collectEmailTemplatePaths(subDirectory)); - } - return paths; -}; diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.spec.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.spec.ts index 5ad03d1191..68c71f0c7d 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.spec.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.spec.ts @@ -10,23 +10,22 @@ describe('getImportedModules()', () => { it('works with this test file', async () => { const contents = await fs.readFile(import.meta.filename, 'utf8'); - expect(getImportedModules(contents)).toEqual([ - 'node:fs', - './get-imported-modules.js', - ]); + expect(getImportedModules(contents)).toEqual({ + staticImports: ['node:fs', './get-imported-modules.js'], + dynamicGlobPrefixes: [], + }); }); it('works with direct exports', () => { const contents = `export * from './component-a'; - export { ComponentB } from './component-b'; + export { ComponentB } from './component-b'; import { ComponentC } from './component-c'; export { ComponentC }`; - expect(getImportedModules(contents)).toEqual([ - './component-a', - './component-b', - './component-c', - ]); + expect(getImportedModules(contents)).toEqual({ + staticImports: ['./component-a', './component-b', './component-c'], + dynamicGlobPrefixes: [], + }); }); it('works with regular imports and double quotes', () => { @@ -51,12 +50,15 @@ import { Component } from '../../my-component'; import * as React from "react"; `; - expect(getImportedModules(contents)).toEqual([ - '@react-email/components', - '@react-email/tailwind', - '../../my-component', - 'react', - ]); + expect(getImportedModules(contents)).toEqual({ + staticImports: [ + '@react-email/components', + '@react-email/tailwind', + '../../my-component', + 'react', + ], + dynamicGlobPrefixes: [], + }); }); it('works with regular imports and single quotes', () => { @@ -81,12 +83,15 @@ import { Component } from '../../my-component'; import * as React from 'react'; `; - expect(getImportedModules(contents)).toEqual([ - 'react-email', - '@react-email/tailwind', - '../../my-component', - 'react', - ]); + expect(getImportedModules(contents)).toEqual({ + staticImports: [ + 'react-email', + '@react-email/tailwind', + '../../my-component', + 'react', + ], + dynamicGlobPrefixes: [], + }); }); it('works with commonjs require with double quotes', () => { @@ -111,12 +116,15 @@ const { Component } = require("../../my-component"); const React = require("react"); `; - expect(getImportedModules(contents)).toEqual([ - '@react-email/components', - '@react-email/tailwind', - '../../my-component', - 'react', - ]); + expect(getImportedModules(contents)).toEqual({ + staticImports: [ + '@react-email/components', + '@react-email/tailwind', + '../../my-component', + 'react', + ], + dynamicGlobPrefixes: [], + }); }); it('works with commonjs require with single quotes', () => { @@ -141,11 +149,45 @@ const { Component } = require('../../my-component'); const React = require('react'); `; - expect(getImportedModules(contents)).toEqual([ - '@react-email/components', - '@react-email/tailwind', - '../../my-component', - 'react', - ]); + expect(getImportedModules(contents)).toEqual({ + staticImports: [ + '@react-email/components', + '@react-email/tailwind', + '../../my-component', + 'react', + ], + dynamicGlobPrefixes: [], + }); + }); + + it('treats dynamic import() with a string literal as a static import', () => { + const contents = `const mod = await import('./my-module.json');`; + expect(getImportedModules(contents)).toEqual({ + staticImports: ['./my-module.json'], + dynamicGlobPrefixes: [], + }); + }); + + it('captures the leading static prefix of dynamic import() template literals', () => { + const contents = ` + i18next.use( + resourcesToBackend( + (lng, ns) => import(\`./messages/\${lng}/\${ns}.json\`), + ), + ); + `; + expect(getImportedModules(contents)).toEqual({ + staticImports: [], + dynamicGlobPrefixes: ['./messages/'], + }); + }); + + it('ignores dynamic import() template literals that start with an interpolation', () => { + // biome-ignore lint/suspicious/noTemplateCurlyInString: the `${base}` here is intentionally raw source text, not a template-string interpolation. + const contents = 'const m = await import(`${base}/file.json`);'; + expect(getImportedModules(contents)).toEqual({ + staticImports: [], + dynamicGlobPrefixes: [], + }); }); }); diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.ts index cd7b6ff3c7..1613bd33c6 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.ts @@ -8,8 +8,23 @@ const traverse = : // @ts-expect-error we keep this check here so that this still works with the dev:preview script's use of tsx traverseModule.default; -export const getImportedModules = (contents: string) => { - const importedPaths: string[] = []; +export interface ImportedModules { + /** Static `import` declarations, `require(...)` calls, and dynamic `import('literal')` calls. */ + staticImports: string[]; + /** + * Leading static prefixes of dynamic `import(\`...${expr}...\`)` calls. + * + * Used to discover directories that should be watched for runtime-resolved + * imports (e.g. `import(\`./messages/${lng}/${ns}.json\`)`). Each entry is + * the substring of the template literal up to the first interpolation; the + * caller resolves it to an absolute directory. + */ + dynamicGlobPrefixes: string[]; +} + +export const getImportedModules = (contents: string): ImportedModules => { + const staticImports: string[] = []; + const dynamicGlobPrefixes: string[] = []; const parsedContents = parse(contents, { sourceType: 'unambiguous', strictMode: false, @@ -19,30 +34,47 @@ export const getImportedModules = (contents: string) => { traverse(parsedContents, { ImportDeclaration({ node }) { - importedPaths.push(node.source.value); + staticImports.push(node.source.value); }, ExportAllDeclaration({ node }) { - importedPaths.push(node.source.value); + staticImports.push(node.source.value); }, ExportNamedDeclaration({ node }) { if (node.source) { - importedPaths.push(node.source.value); + staticImports.push(node.source.value); } }, TSExternalModuleReference({ node }) { - importedPaths.push(node.expression.value); + staticImports.push(node.expression.value); }, CallExpression({ node }) { if ('name' in node.callee && node.callee.name === 'require') { if (node.arguments.length === 1) { const importPathNode = node.arguments[0]!; if (importPathNode!.type === 'StringLiteral') { - importedPaths.push(importPathNode.value); + staticImports.push(importPathNode.value); + } + } + return; + } + + // `import(...)` is parsed as a CallExpression whose callee is `Import`. + if (node.callee.type === 'Import' && node.arguments.length === 1) { + const argument = node.arguments[0]!; + if (argument.type === 'StringLiteral') { + staticImports.push(argument.value); + return; + } + if (argument.type === 'TemplateLiteral' && argument.quasis.length > 0) { + const firstQuasi = argument.quasis[0]!; + const leadingStatic = firstQuasi.value.cooked ?? firstQuasi.value.raw; + if (leadingStatic.length > 0) { + dynamicGlobPrefixes.push(leadingStatic); } } } }, }); - return importedPaths; + return { staticImports, dynamicGlobPrefixes }; }; diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.spec.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.spec.ts deleted file mode 100644 index 6987764b6b..0000000000 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import type { EmailsDirectory } from '../../get-emails-directory-metadata.js'; -import { - collectEmailTemplatePaths, - isUnderAnyPath, -} from './extra-watch-paths.js'; - -describe('isUnderAnyPath()', () => { - const roots = [path.resolve('/proj/i18n'), path.resolve('/proj/locales')]; - - it('matches an exact root', () => { - expect(isUnderAnyPath(path.resolve('/proj/i18n'), roots)).toBe(true); - }); - - it('matches a nested file under a root', () => { - expect( - isUnderAnyPath(path.resolve('/proj/i18n/messages/en.json'), roots), - ).toBe(true); - }); - - it('does not match a sibling whose name shares a prefix', () => { - expect(isUnderAnyPath(path.resolve('/proj/i18n-extra/file'), roots)).toBe( - false, - ); - }); - - it('does not match an unrelated path', () => { - expect( - isUnderAnyPath(path.resolve('/proj/emails/welcome.tsx'), roots), - ).toBe(false); - }); - - it('returns false when no roots are configured', () => { - expect(isUnderAnyPath(path.resolve('/proj/i18n/x.json'), [])).toBe(false); - }); -}); - -describe('collectEmailTemplatePaths()', () => { - it('flattens templates from the root and nested subdirectories', () => { - const root: EmailsDirectory = { - absolutePath: '/proj/emails', - relativePath: '', - directoryName: 'emails', - emailFilenames: ['welcome.tsx', 'goodbye.tsx'], - subDirectories: [ - { - absolutePath: '/proj/emails/marketing', - relativePath: 'marketing', - directoryName: 'marketing', - emailFilenames: ['promo.tsx'], - subDirectories: [ - { - absolutePath: '/proj/emails/marketing/seasonal', - relativePath: 'marketing/seasonal', - directoryName: 'seasonal', - emailFilenames: ['holiday.tsx'], - subDirectories: [], - }, - ], - }, - ], - }; - - expect(collectEmailTemplatePaths(root)).toEqual([ - path.join('/proj/emails', 'welcome.tsx'), - path.join('/proj/emails', 'goodbye.tsx'), - path.join('/proj/emails/marketing', 'promo.tsx'), - path.join('/proj/emails/marketing/seasonal', 'holiday.tsx'), - ]); - }); - - it('returns an empty list for an empty directory', () => { - expect( - collectEmailTemplatePaths({ - absolutePath: '/proj/emails', - relativePath: '', - directoryName: 'emails', - emailFilenames: [], - subDirectories: [], - }), - ).toEqual([]); - }); -}); diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.ts index ca040c6417..420e29cbcd 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.ts @@ -3,18 +3,12 @@ import path from 'node:path'; import { watch } from 'chokidar'; import debounce from 'debounce'; import { type Socket, Server as SocketServer } from 'socket.io'; -import { getEmailsDirectoryMetadata } from '../../get-emails-directory-metadata.js'; import type { HotReloadChange } from '../../types/hot-reload-change.js'; import { createDependencyGraph } from './create-dependency-graph.js'; -import { - collectEmailTemplatePaths, - isUnderAnyPath, -} from './extra-watch-paths.js'; export const setupHotreloading = async ( devServer: http.Server, emailDirRelativePath: string, - extraWatchPaths: string[] = [], ) => { let clients: Socket[] = []; const io = new SocketServer(devServer); @@ -53,15 +47,11 @@ export const setupHotreloading = async ( emailDirRelativePath, ); - const resolvedExtraWatchPaths = extraWatchPaths.map((p) => - path.resolve(process.cwd(), p), - ); - - const isUnderExtraWatchPath = (absolutePath: string) => - isUnderAnyPath(absolutePath, resolvedExtraWatchPaths); - - const [dependencyGraph, updateDependencyGraph, { resolveDependentsOf }] = - await createDependencyGraph(absolutePathToEmailsDirectory); + const [ + dependencyGraph, + updateDependencyGraph, + { resolveDependentsOf, getGlobDependencyDirectories }, + ] = await createDependencyGraph(absolutePathToEmailsDirectory); const watcher = watch('', { ignoreInitial: true, @@ -79,8 +69,12 @@ export const setupHotreloading = async ( watcher.add(p); } - if (resolvedExtraWatchPaths.length > 0) { - watcher.add(resolvedExtraWatchPaths); + // Directories targeted by dynamic `import(\`./prefix/${expr}\`)` calls. + // These files are resolved at runtime so they never appear in the static + // dependency graph; we still want their changes to refresh the preview. + let watchedGlobDirectories: string[] = getGlobDependencyDirectories(); + for (const directory of watchedGlobDirectories) { + watcher.add(directory); } const exit = async () => { @@ -118,6 +112,21 @@ export const setupHotreloading = async ( } filesOutsideEmailsDirectory = newFilesOutsideEmailsDirectory; + // Glob directories can change as templates are edited (a new dynamic + // import is added or removed); reconcile chokidar's set with the graph. + const newWatchedGlobDirectories = getGlobDependencyDirectories(); + for (const directory of watchedGlobDirectories) { + if (!newWatchedGlobDirectories.includes(directory)) { + watcher.unwatch(directory); + } + } + for (const directory of newWatchedGlobDirectories) { + if (!watchedGlobDirectories.includes(directory)) { + watcher.add(directory); + } + } + watchedGlobDirectories = newWatchedGlobDirectories; + changes.push({ event, filename: relativePathToChangeTarget, @@ -132,27 +141,6 @@ export const setupHotreloading = async ( }); } - // Files in --watch paths are loaded at runtime (e.g. i18n message JSON read - // through a backend) and never enter the static dependency graph, so we - // can't tell which template depends on them. Reload every template instead. - if (isUnderExtraWatchPath(pathToChangeTarget)) { - const metadata = await getEmailsDirectoryMetadata( - absolutePathToEmailsDirectory, - true, - ); - if (metadata) { - for (const templatePath of collectEmailTemplatePaths(metadata)) { - changes.push({ - event: 'change' as const, - filename: path.relative( - absolutePathToEmailsDirectory, - templatePath, - ), - }); - } - } - } - reload(); }); diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/test/dynamic-import-graph/messages/en/common.json b/packages/react-email/src/cli/utils/preview/hot-reloading/test/dynamic-import-graph/messages/en/common.json new file mode 100644 index 0000000000..c21310b9a7 --- /dev/null +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/test/dynamic-import-graph/messages/en/common.json @@ -0,0 +1 @@ +{ "hello": "Hello" } diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/test/dynamic-import-graph/template.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/test/dynamic-import-graph/template.ts new file mode 100644 index 0000000000..567cc45e4a --- /dev/null +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/test/dynamic-import-graph/template.ts @@ -0,0 +1,4 @@ +/** biome-ignore-all lint/correctness/noUnusedVariables: used in testing */ + +export const loadMessages = (lng: string, ns: string) => + import(`./messages/${lng}/${ns}.json`); From 3c7de6fb82424f3a40f352bb078193a57a962f22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20Karakanl=C4=B1?= <102619516+actuallyzefe@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:42:28 +0300 Subject: [PATCH 05/12] ci: re-trigger workflow From 47648e66b46ddfa57fe701dc66553dd9fc512be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20Karakanl=C4=B1?= <102619516+actuallyzefe@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:32:01 +0300 Subject: [PATCH 06/12] fix(cli): enhance dynamic import handling with tsconfig path alias resolution This commit introduces a new function to resolve tsconfig path aliases for dynamic imports, allowing for more flexible module loading. It updates the dependency graph to accommodate these changes and adds tests to verify the correct resolution of aliased paths. Additionally, it refactors the `isUnderDirectory` function to handle edge cases more effectively. --- .../hot-reloading/create-dependency-graph.ts | 37 ++++++++--- .../dynamic-import-graph.spec.ts | 63 ++++++++++++++++++- .../get-imported-modules.spec.ts | 8 +++ .../hot-reloading/get-imported-modules.ts | 8 +++ .../hot-reloading/resolve-path-aliases.ts | 33 ++++++++++ .../src/locales/tr/common.json | 1 + .../test/aliased-import-graph/src/template.ts | 5 ++ .../test/aliased-import-graph/tsconfig.json | 8 +++ 8 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-import-graph/src/locales/tr/common.json create mode 100644 packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-import-graph/src/template.ts create mode 100644 packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-import-graph/tsconfig.json diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.ts index 157f2a2ffd..fe43e9e97c 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.ts @@ -2,7 +2,10 @@ import { existsSync, promises as fs, statSync } from 'node:fs'; import path from 'node:path'; import type { EventName } from 'chokidar/handler.js'; import { getImportedModules } from './get-imported-modules.js'; -import { resolvePathAliases } from './resolve-path-aliases.js'; +import { + resolveAliasedDirectoryPrefix, + resolvePathAliases, +} from './resolve-path-aliases.js'; interface Module { path: string; @@ -77,8 +80,14 @@ const checkFileExtensionsUntilItExists = ( } }; -const isUnderDirectory = (filePath: string, directoryPath: string) => - filePath === directoryPath || filePath.startsWith(directoryPath + path.sep); +export const isUnderDirectory = (filePath: string, directoryPath: string) => { + if (filePath === directoryPath) return true; + // Avoid double-separator when directoryPath is a root like `/` or `C:\`. + const prefix = directoryPath.endsWith(path.sep) + ? directoryPath + : directoryPath + path.sep; + return filePath.startsWith(prefix); +}; /** * Resolves the leading static prefix of a dynamic `import()` template literal @@ -91,12 +100,24 @@ const resolveGlobPrefixToDirectory = ( prefix: string, modulePath: string, ): string | undefined => { - // Bail on bare module specifiers (e.g. `import(\`some-pkg/${name}\`)`). - const isRelative = prefix.startsWith('.') || path.isAbsolute(prefix); - if (!isRelative) return undefined; - const moduleDirectory = path.dirname(modulePath); - const resolvedPrefix = path.resolve(moduleDirectory, prefix); + + // Try to resolve tsconfig path aliases (e.g. `@/messages/`) before deciding + // a non-relative prefix is a bare module specifier. + let resolvedPrefix: string; + const isRelative = prefix.startsWith('.') || path.isAbsolute(prefix); + if (isRelative) { + resolvedPrefix = path.resolve(moduleDirectory, prefix); + } else { + const trimmed = prefix.replace(/[/\\]+$/, ''); + if (trimmed.length === 0) return undefined; + const aliased = resolveAliasedDirectoryPrefix(trimmed, moduleDirectory); + if (aliased === undefined) { + // Not an alias — actual bare module specifier. Skip. + return undefined; + } + resolvedPrefix = aliased; + } // Pick the directory portion of the prefix. If the prefix doesn't end in a // separator, the last segment is treated as a partial filename and dropped. diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts index 065ac2a44c..56045ae0c5 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts @@ -1,5 +1,8 @@ import path from 'node:path'; -import { createDependencyGraph } from './create-dependency-graph.js'; +import { + createDependencyGraph, + isUnderDirectory, +} from './create-dependency-graph.js'; vi.mock('@babel/traverse', async () => { const traverse = await vi.importActual('@babel/traverse'); @@ -11,6 +14,11 @@ const fixtureDirectory = path.join( './test/dynamic-import-graph', ); +const aliasedFixtureDirectory = path.join( + import.meta.dirname, + './test/aliased-import-graph/src', +); + describe('createDependencyGraph() with dynamic imports', () => { it('exposes the directory of a template-literal `import()` as a glob dependency and resolves runtime files to the importing module', async () => { const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = @@ -28,4 +36,57 @@ describe('createDependencyGraph() with dynamic imports', () => { resolveDependentsOf(path.join(messagesDirectory, 'en', 'common.json')), ).toEqual([templatePath]); }); + + it('resolves tsconfig path aliases in dynamic import template literals', async () => { + const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = + await createDependencyGraph(aliasedFixtureDirectory); + + // The template uses `@/locales/${lng}/${ns}.json`, and the fixture's + // tsconfig maps `@/*` -> `src/*`. The resolved glob directory must be the + // real `src/locales/` folder, not be discarded as a bare specifier. + const localesDirectory = path.join(aliasedFixtureDirectory, 'locales'); + const templatePath = path.join(aliasedFixtureDirectory, 'template.ts'); + + expect(getGlobDependencyDirectories()).toEqual([localesDirectory]); + + expect( + resolveDependentsOf(path.join(localesDirectory, 'tr', 'common.json')), + ).toEqual([templatePath]); + }); +}); + +describe('isUnderDirectory()', () => { + it('matches a nested file', () => { + expect( + isUnderDirectory( + path.resolve('/proj/messages/en.json'), + path.resolve('/proj/messages'), + ), + ).toBe(true); + }); + + it('matches the directory itself', () => { + expect( + isUnderDirectory( + path.resolve('/proj/messages'), + path.resolve('/proj/messages'), + ), + ).toBe(true); + }); + + it('does not match a sibling sharing a prefix', () => { + expect( + isUnderDirectory( + path.resolve('/proj/messages-extra/x'), + path.resolve('/proj/messages'), + ), + ).toBe(false); + }); + + it('handles root directory paths without producing a double separator', () => { + // Regression: previously `'/' + path.sep === '//'`, so `/foo` was reported + // as not under `/`. + expect(isUnderDirectory(path.resolve('/foo/bar'), path.sep)).toBe(true); + expect(isUnderDirectory(path.sep, path.sep)).toBe(true); + }); }); diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.spec.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.spec.ts index 68c71f0c7d..20684961e6 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.spec.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.spec.ts @@ -190,4 +190,12 @@ const React = require('react'); dynamicGlobPrefixes: [], }); }); + + it('treats a dynamic import() template literal with no interpolation as a static import', () => { + const contents = 'const m = await import(`./my-module.json`);'; + expect(getImportedModules(contents)).toEqual({ + staticImports: ['./my-module.json'], + dynamicGlobPrefixes: [], + }); + }); }); diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.ts index 1613bd33c6..40671a7b87 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.ts @@ -66,6 +66,14 @@ export const getImportedModules = (contents: string): ImportedModules => { return; } if (argument.type === 'TemplateLiteral' && argument.quasis.length > 0) { + if (argument.expressions.length === 0) { + const onlyQuasi = argument.quasis[0]!; + const staticPath = onlyQuasi.value.cooked ?? onlyQuasi.value.raw; + if (staticPath.length > 0) { + staticImports.push(staticPath); + } + return; + } const firstQuasi = argument.quasis[0]!; const leadingStatic = firstQuasi.value.cooked ?? firstQuasi.value.raw; if (leadingStatic.length > 0) { diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/resolve-path-aliases.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/resolve-path-aliases.ts index c7a42aa71f..c9a5853cc4 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/resolve-path-aliases.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/resolve-path-aliases.ts @@ -30,3 +30,36 @@ export const resolvePathAliases = ( return importPaths; }; + +/** + * Resolves a tsconfig path alias prefix (e.g. `@/messages/`) to an absolute + * directory on disk. Unlike {@link resolvePathAliases}, this does not require + * the target to exist as a real file — it's meant for dynamic-import glob + * prefixes that point at a directory containing runtime-resolved files. + * + * Returns `undefined` when the prefix isn't an alias defined in the closest + * tsconfig (i.e. it's a true bare module specifier). + */ +export const resolveAliasedDirectoryPrefix = ( + prefix: string, + projectPath: string, +): string | undefined => { + const configLoadResult = loadConfig(projectPath); + if (configLoadResult.resultType !== 'success') return undefined; + + const matchPath = createMatchPath( + configLoadResult.absoluteBaseUrl, + configLoadResult.paths, + ); + // Pass `() => true` so matchPath resolves directories (which don't have a + // file extension to probe). + const resolved = matchPath(prefix, undefined, () => true, [ + '.tsx', + '.ts', + '.js', + '.jsx', + '.cjs', + '.mjs', + ]); + return resolved ?? undefined; +}; diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-import-graph/src/locales/tr/common.json b/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-import-graph/src/locales/tr/common.json new file mode 100644 index 0000000000..1bd2f42112 --- /dev/null +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-import-graph/src/locales/tr/common.json @@ -0,0 +1 @@ +{ "hello": "merhaba" } diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-import-graph/src/template.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-import-graph/src/template.ts new file mode 100644 index 0000000000..d959eeff93 --- /dev/null +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-import-graph/src/template.ts @@ -0,0 +1,5 @@ +/** biome-ignore-all lint/correctness/noUnusedVariables: used in testing */ + +export const loadMessages = (lng: string, ns: string) => + // @ts-expect-error -- alias resolution provided by the test's tsconfig + import(`@/locales/${lng}/${ns}.json`); diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-import-graph/tsconfig.json b/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-import-graph/tsconfig.json new file mode 100644 index 0000000000..2c8ee2bb01 --- /dev/null +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-import-graph/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } +} From 77c236436f8b394fae59467d3ccafb925950a59b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20Karakanl=C4=B1?= <102619516+actuallyzefe@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:42:06 +0300 Subject: [PATCH 07/12] fix(cli): correct alias fallback in dynamic-import directory resolution --- .../dynamic-import-graph.spec.ts | 29 +++++++++++++++++++ .../hot-reloading/resolve-path-aliases.ts | 22 +++++++++++--- .../lib/locales/de/common.json | 1 + .../aliased-fallback-graph/src/template.ts | 5 ++++ .../test/aliased-fallback-graph/tsconfig.json | 8 +++++ 5 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-fallback-graph/lib/locales/de/common.json create mode 100644 packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-fallback-graph/src/template.ts create mode 100644 packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-fallback-graph/tsconfig.json diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts index 56045ae0c5..300491f4aa 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts @@ -19,6 +19,11 @@ const aliasedFixtureDirectory = path.join( './test/aliased-import-graph/src', ); +const aliasedFallbackFixtureDirectory = path.join( + import.meta.dirname, + './test/aliased-fallback-graph/src', +); + describe('createDependencyGraph() with dynamic imports', () => { it('exposes the directory of a template-literal `import()` as a glob dependency and resolves runtime files to the importing module', async () => { const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = @@ -37,6 +42,30 @@ describe('createDependencyGraph() with dynamic imports', () => { ).toEqual([templatePath]); }); + it('falls through to a later alias candidate when the first does not exist on disk', async () => { + // tsconfig maps `@/*` to `["src/*", "lib/*"]`. The fixture has no + // `src/locales` directory but does have `lib/locales`. The resolver must + // skip the missing first candidate instead of returning it. + const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = + await createDependencyGraph(aliasedFallbackFixtureDirectory); + + const localesDirectory = path.join( + path.dirname(aliasedFallbackFixtureDirectory), + 'lib', + 'locales', + ); + const templatePath = path.join( + aliasedFallbackFixtureDirectory, + 'template.ts', + ); + + expect(getGlobDependencyDirectories()).toEqual([localesDirectory]); + + expect( + resolveDependentsOf(path.join(localesDirectory, 'de', 'common.json')), + ).toEqual([templatePath]); + }); + it('resolves tsconfig path aliases in dynamic import template literals', async () => { const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = await createDependencyGraph(aliasedFixtureDirectory); diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/resolve-path-aliases.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/resolve-path-aliases.ts index c9a5853cc4..a1881139eb 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/resolve-path-aliases.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/resolve-path-aliases.ts @@ -1,3 +1,4 @@ +import { statSync } from 'node:fs'; import path from 'node:path'; import { createMatchPath, loadConfig } from 'tsconfig-paths'; @@ -31,14 +32,29 @@ export const resolvePathAliases = ( return importPaths; }; +const isExistingDirectory = (candidate: string): boolean => { + try { + return statSync(candidate).isDirectory(); + } catch (_) { + return false; + } +}; + /** * Resolves a tsconfig path alias prefix (e.g. `@/messages/`) to an absolute * directory on disk. Unlike {@link resolvePathAliases}, this does not require * the target to exist as a real file — it's meant for dynamic-import glob * prefixes that point at a directory containing runtime-resolved files. * + * When an alias maps to multiple candidate replacements (e.g. + * `"@/*": ["src/*", "lib/*"]`), the existence check below lets `matchPath` + * fall through to the first candidate that actually exists as a directory. + * Passing a blanket `() => true` would short-circuit on the first candidate + * regardless of whether it exists. + * * Returns `undefined` when the prefix isn't an alias defined in the closest - * tsconfig (i.e. it's a true bare module specifier). + * tsconfig (i.e. it's a true bare module specifier) or none of the candidates + * resolve to an existing directory. */ export const resolveAliasedDirectoryPrefix = ( prefix: string, @@ -51,9 +67,7 @@ export const resolveAliasedDirectoryPrefix = ( configLoadResult.absoluteBaseUrl, configLoadResult.paths, ); - // Pass `() => true` so matchPath resolves directories (which don't have a - // file extension to probe). - const resolved = matchPath(prefix, undefined, () => true, [ + const resolved = matchPath(prefix, undefined, isExistingDirectory, [ '.tsx', '.ts', '.js', diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-fallback-graph/lib/locales/de/common.json b/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-fallback-graph/lib/locales/de/common.json new file mode 100644 index 0000000000..1dbb38d415 --- /dev/null +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-fallback-graph/lib/locales/de/common.json @@ -0,0 +1 @@ +{ "hello": "Hallo" } diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-fallback-graph/src/template.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-fallback-graph/src/template.ts new file mode 100644 index 0000000000..d959eeff93 --- /dev/null +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-fallback-graph/src/template.ts @@ -0,0 +1,5 @@ +/** biome-ignore-all lint/correctness/noUnusedVariables: used in testing */ + +export const loadMessages = (lng: string, ns: string) => + // @ts-expect-error -- alias resolution provided by the test's tsconfig + import(`@/locales/${lng}/${ns}.json`); diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-fallback-graph/tsconfig.json b/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-fallback-graph/tsconfig.json new file mode 100644 index 0000000000..5bab72776e --- /dev/null +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/test/aliased-fallback-graph/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*", "lib/*"] + } + } +} From 723087bb53a55c7e93e5bc46d0f72a6af770731a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20Karakanl=C4=B1?= <102619516+actuallyzefe@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:13:40 +0300 Subject: [PATCH 08/12] remove overly verbose comments from hot-reloading modules --- .../hot-reloading/create-dependency-graph.ts | 24 ------------------- .../dynamic-import-graph.spec.ts | 11 --------- .../hot-reloading/get-imported-modules.ts | 10 -------- .../hot-reloading/resolve-path-aliases.ts | 16 ------------- .../hot-reloading/setup-hot-reloading.ts | 2 -- 5 files changed, 63 deletions(-) diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.ts index fe43e9e97c..d765afbc22 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.ts @@ -13,11 +13,6 @@ interface Module { dependencyPaths: string[]; dependentPaths: string[]; - /** - * Absolute directory paths discovered from dynamic `import(\`./prefix/${expr}\`)` - * call sites. Any file change inside one of these directories should be - * treated as a change to this module. - */ globDependencyPaths: string[]; moduleDependencies: string[]; @@ -82,28 +77,18 @@ const checkFileExtensionsUntilItExists = ( export const isUnderDirectory = (filePath: string, directoryPath: string) => { if (filePath === directoryPath) return true; - // Avoid double-separator when directoryPath is a root like `/` or `C:\`. const prefix = directoryPath.endsWith(path.sep) ? directoryPath : directoryPath + path.sep; return filePath.startsWith(prefix); }; -/** - * Resolves the leading static prefix of a dynamic `import()` template literal - * to an absolute directory path on disk. - * - * Returns `undefined` when the prefix is too generic (e.g. `./` or `../`) to - * be useful — watching the entire module directory would over-trigger reloads. - */ const resolveGlobPrefixToDirectory = ( prefix: string, modulePath: string, ): string | undefined => { const moduleDirectory = path.dirname(modulePath); - // Try to resolve tsconfig path aliases (e.g. `@/messages/`) before deciding - // a non-relative prefix is a bare module specifier. let resolvedPrefix: string; const isRelative = prefix.startsWith('.') || path.isAbsolute(prefix); if (isRelative) { @@ -113,21 +98,16 @@ const resolveGlobPrefixToDirectory = ( if (trimmed.length === 0) return undefined; const aliased = resolveAliasedDirectoryPrefix(trimmed, moduleDirectory); if (aliased === undefined) { - // Not an alias — actual bare module specifier. Skip. return undefined; } resolvedPrefix = aliased; } - // Pick the directory portion of the prefix. If the prefix doesn't end in a - // separator, the last segment is treated as a partial filename and dropped. const directory = prefix.endsWith('/') || prefix.endsWith(path.sep) ? resolvedPrefix : path.dirname(resolvedPrefix); - // Don't watch the module's own directory or any of its ancestors — that - // would either be redundant or far too broad. if (isUnderDirectory(moduleDirectory, directory)) return undefined; if (!existsSync(directory)) return undefined; @@ -426,10 +406,6 @@ export const createDependencyGraph = async (directory: string) => { const dependentPaths = new Set(); const stack: string[] = [pathToModule]; - // Seed the stack with modules that declared a glob covering this path. - // We do this even when the path corresponds to a known node, so e.g. - // a JSON file that's both statically and dynamically imported reloads - // through both routes. for (const module of Object.values(graph)) { for (const globDirectory of module.globDependencyPaths) { if ( diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts index 300491f4aa..a91c132f9e 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts @@ -34,18 +34,12 @@ describe('createDependencyGraph() with dynamic imports', () => { expect(getGlobDependencyDirectories()).toEqual([messagesDirectory]); - // A JSON file matched by the dynamic import has no graph node, but the - // importing module should still be reported as a dependent so the preview - // reloads when it changes. expect( resolveDependentsOf(path.join(messagesDirectory, 'en', 'common.json')), ).toEqual([templatePath]); }); it('falls through to a later alias candidate when the first does not exist on disk', async () => { - // tsconfig maps `@/*` to `["src/*", "lib/*"]`. The fixture has no - // `src/locales` directory but does have `lib/locales`. The resolver must - // skip the missing first candidate instead of returning it. const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = await createDependencyGraph(aliasedFallbackFixtureDirectory); @@ -70,9 +64,6 @@ describe('createDependencyGraph() with dynamic imports', () => { const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = await createDependencyGraph(aliasedFixtureDirectory); - // The template uses `@/locales/${lng}/${ns}.json`, and the fixture's - // tsconfig maps `@/*` -> `src/*`. The resolved glob directory must be the - // real `src/locales/` folder, not be discarded as a bare specifier. const localesDirectory = path.join(aliasedFixtureDirectory, 'locales'); const templatePath = path.join(aliasedFixtureDirectory, 'template.ts'); @@ -113,8 +104,6 @@ describe('isUnderDirectory()', () => { }); it('handles root directory paths without producing a double separator', () => { - // Regression: previously `'/' + path.sep === '//'`, so `/foo` was reported - // as not under `/`. expect(isUnderDirectory(path.resolve('/foo/bar'), path.sep)).toBe(true); expect(isUnderDirectory(path.sep, path.sep)).toBe(true); }); diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.ts index 40671a7b87..9f2d5c619c 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.ts @@ -9,16 +9,7 @@ const traverse = traverseModule.default; export interface ImportedModules { - /** Static `import` declarations, `require(...)` calls, and dynamic `import('literal')` calls. */ staticImports: string[]; - /** - * Leading static prefixes of dynamic `import(\`...${expr}...\`)` calls. - * - * Used to discover directories that should be watched for runtime-resolved - * imports (e.g. `import(\`./messages/${lng}/${ns}.json\`)`). Each entry is - * the substring of the template literal up to the first interpolation; the - * caller resolves it to an absolute directory. - */ dynamicGlobPrefixes: string[]; } @@ -58,7 +49,6 @@ export const getImportedModules = (contents: string): ImportedModules => { return; } - // `import(...)` is parsed as a CallExpression whose callee is `Import`. if (node.callee.type === 'Import' && node.arguments.length === 1) { const argument = node.arguments[0]!; if (argument.type === 'StringLiteral') { diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/resolve-path-aliases.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/resolve-path-aliases.ts index a1881139eb..9eb0ab0022 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/resolve-path-aliases.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/resolve-path-aliases.ts @@ -40,22 +40,6 @@ const isExistingDirectory = (candidate: string): boolean => { } }; -/** - * Resolves a tsconfig path alias prefix (e.g. `@/messages/`) to an absolute - * directory on disk. Unlike {@link resolvePathAliases}, this does not require - * the target to exist as a real file — it's meant for dynamic-import glob - * prefixes that point at a directory containing runtime-resolved files. - * - * When an alias maps to multiple candidate replacements (e.g. - * `"@/*": ["src/*", "lib/*"]`), the existence check below lets `matchPath` - * fall through to the first candidate that actually exists as a directory. - * Passing a blanket `() => true` would short-circuit on the first candidate - * regardless of whether it exists. - * - * Returns `undefined` when the prefix isn't an alias defined in the closest - * tsconfig (i.e. it's a true bare module specifier) or none of the candidates - * resolve to an existing directory. - */ export const resolveAliasedDirectoryPrefix = ( prefix: string, projectPath: string, diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.ts index 420e29cbcd..d05235005c 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.ts @@ -112,8 +112,6 @@ export const setupHotreloading = async ( } filesOutsideEmailsDirectory = newFilesOutsideEmailsDirectory; - // Glob directories can change as templates are edited (a new dynamic - // import is added or removed); reconcile chokidar's set with the graph. const newWatchedGlobDirectories = getGlobDependencyDirectories(); for (const directory of watchedGlobDirectories) { if (!newWatchedGlobDirectories.includes(directory)) { From 5584ee9f41b9fbea2a39c5d389e0cec7e3204130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20Karakanl=C4=B1?= <102619516+actuallyzefe@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:57:49 +0300 Subject: [PATCH 09/12] test(cli): merge dynamic-import-graph specs into create-dependency-graph --- .../create-dependency-graph.spec.ts | 101 ++++++++++++++++ .../dynamic-import-graph.spec.ts | 110 ------------------ 2 files changed, 101 insertions(+), 110 deletions(-) delete mode 100644 packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.spec.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.spec.ts index 0212b2dc86..4d965cfc4a 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.spec.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.spec.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { createDependencyGraph, type DependencyGraph, + isUnderDirectory, } from './create-dependency-graph.js'; const testingDiretctory = path.join( @@ -226,3 +227,103 @@ import {} from './file-b'; ).not.toContain(pathToTemporaryFile); }); }); + +describe('createDependencyGraph() with dynamic imports', () => { + const fixtureDirectory = path.join( + import.meta.dirname, + './test/dynamic-import-graph', + ); + + const aliasedFixtureDirectory = path.join( + import.meta.dirname, + './test/aliased-import-graph/src', + ); + + const aliasedFallbackFixtureDirectory = path.join( + import.meta.dirname, + './test/aliased-fallback-graph/src', + ); + + it('exposes the directory of a template-literal `import()` as a glob dependency and resolves runtime files to the importing module', async () => { + const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = + await createDependencyGraph(fixtureDirectory); + + const messagesDirectory = path.join(fixtureDirectory, 'messages'); + const templatePath = path.join(fixtureDirectory, 'template.ts'); + + expect(getGlobDependencyDirectories()).toEqual([messagesDirectory]); + + expect( + resolveDependentsOf(path.join(messagesDirectory, 'en', 'common.json')), + ).toEqual([templatePath]); + }); + + it('falls through to a later alias candidate when the first does not exist on disk', async () => { + const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = + await createDependencyGraph(aliasedFallbackFixtureDirectory); + + const localesDirectory = path.join( + path.dirname(aliasedFallbackFixtureDirectory), + 'lib', + 'locales', + ); + const templatePath = path.join( + aliasedFallbackFixtureDirectory, + 'template.ts', + ); + + expect(getGlobDependencyDirectories()).toEqual([localesDirectory]); + + expect( + resolveDependentsOf(path.join(localesDirectory, 'de', 'common.json')), + ).toEqual([templatePath]); + }); + + it('resolves tsconfig path aliases in dynamic import template literals', async () => { + const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = + await createDependencyGraph(aliasedFixtureDirectory); + + const localesDirectory = path.join(aliasedFixtureDirectory, 'locales'); + const templatePath = path.join(aliasedFixtureDirectory, 'template.ts'); + + expect(getGlobDependencyDirectories()).toEqual([localesDirectory]); + + expect( + resolveDependentsOf(path.join(localesDirectory, 'tr', 'common.json')), + ).toEqual([templatePath]); + }); +}); + +describe('isUnderDirectory()', () => { + it('matches a nested file', () => { + expect( + isUnderDirectory( + path.resolve('/proj/messages/en.json'), + path.resolve('/proj/messages'), + ), + ).toBe(true); + }); + + it('matches the directory itself', () => { + expect( + isUnderDirectory( + path.resolve('/proj/messages'), + path.resolve('/proj/messages'), + ), + ).toBe(true); + }); + + it('does not match a sibling sharing a prefix', () => { + expect( + isUnderDirectory( + path.resolve('/proj/messages-extra/x'), + path.resolve('/proj/messages'), + ), + ).toBe(false); + }); + + it('handles root directory paths without producing a double separator', () => { + expect(isUnderDirectory(path.resolve('/foo/bar'), path.sep)).toBe(true); + expect(isUnderDirectory(path.sep, path.sep)).toBe(true); + }); +}); diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts deleted file mode 100644 index a91c132f9e..0000000000 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/dynamic-import-graph.spec.ts +++ /dev/null @@ -1,110 +0,0 @@ -import path from 'node:path'; -import { - createDependencyGraph, - isUnderDirectory, -} from './create-dependency-graph.js'; - -vi.mock('@babel/traverse', async () => { - const traverse = await vi.importActual('@babel/traverse'); - return { default: traverse }; -}); - -const fixtureDirectory = path.join( - import.meta.dirname, - './test/dynamic-import-graph', -); - -const aliasedFixtureDirectory = path.join( - import.meta.dirname, - './test/aliased-import-graph/src', -); - -const aliasedFallbackFixtureDirectory = path.join( - import.meta.dirname, - './test/aliased-fallback-graph/src', -); - -describe('createDependencyGraph() with dynamic imports', () => { - it('exposes the directory of a template-literal `import()` as a glob dependency and resolves runtime files to the importing module', async () => { - const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = - await createDependencyGraph(fixtureDirectory); - - const messagesDirectory = path.join(fixtureDirectory, 'messages'); - const templatePath = path.join(fixtureDirectory, 'template.ts'); - - expect(getGlobDependencyDirectories()).toEqual([messagesDirectory]); - - expect( - resolveDependentsOf(path.join(messagesDirectory, 'en', 'common.json')), - ).toEqual([templatePath]); - }); - - it('falls through to a later alias candidate when the first does not exist on disk', async () => { - const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = - await createDependencyGraph(aliasedFallbackFixtureDirectory); - - const localesDirectory = path.join( - path.dirname(aliasedFallbackFixtureDirectory), - 'lib', - 'locales', - ); - const templatePath = path.join( - aliasedFallbackFixtureDirectory, - 'template.ts', - ); - - expect(getGlobDependencyDirectories()).toEqual([localesDirectory]); - - expect( - resolveDependentsOf(path.join(localesDirectory, 'de', 'common.json')), - ).toEqual([templatePath]); - }); - - it('resolves tsconfig path aliases in dynamic import template literals', async () => { - const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = - await createDependencyGraph(aliasedFixtureDirectory); - - const localesDirectory = path.join(aliasedFixtureDirectory, 'locales'); - const templatePath = path.join(aliasedFixtureDirectory, 'template.ts'); - - expect(getGlobDependencyDirectories()).toEqual([localesDirectory]); - - expect( - resolveDependentsOf(path.join(localesDirectory, 'tr', 'common.json')), - ).toEqual([templatePath]); - }); -}); - -describe('isUnderDirectory()', () => { - it('matches a nested file', () => { - expect( - isUnderDirectory( - path.resolve('/proj/messages/en.json'), - path.resolve('/proj/messages'), - ), - ).toBe(true); - }); - - it('matches the directory itself', () => { - expect( - isUnderDirectory( - path.resolve('/proj/messages'), - path.resolve('/proj/messages'), - ), - ).toBe(true); - }); - - it('does not match a sibling sharing a prefix', () => { - expect( - isUnderDirectory( - path.resolve('/proj/messages-extra/x'), - path.resolve('/proj/messages'), - ), - ).toBe(false); - }); - - it('handles root directory paths without producing a double separator', () => { - expect(isUnderDirectory(path.resolve('/foo/bar'), path.sep)).toBe(true); - expect(isUnderDirectory(path.sep, path.sep)).toBe(true); - }); -}); From 0a2999c1d388c53aa95aa0ef534dcbd55d76eb7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20Karakanl=C4=B1?= <102619516+actuallyzefe@users.noreply.github.com> Date: Wed, 29 Apr 2026 02:34:48 +0300 Subject: [PATCH 10/12] refactor(react-email): rename globDependencyPaths to dynamicDependencyDirectories and update related logic This change improves clarity by renaming `globDependencyPaths` to `dynamicDependencyDirectories` across the codebase. The update includes adjustments in the dependency graph creation and related tests to reflect this new naming convention, enhancing the understanding of dynamic imports in the context of the project. --- .changeset/rich-dryers-lay.md | 5 ++ .../create-dependency-graph.spec.ts | 37 +++++++--- .../hot-reloading/create-dependency-graph.ts | 69 ++++++++----------- .../get-imported-modules.spec.ts | 20 +++--- .../hot-reloading/get-imported-modules.ts | 8 +-- .../hot-reloading/setup-hot-reloading.ts | 32 +++++---- 6 files changed, 93 insertions(+), 78 deletions(-) create mode 100644 .changeset/rich-dryers-lay.md diff --git a/.changeset/rich-dryers-lay.md b/.changeset/rich-dryers-lay.md new file mode 100644 index 0000000000..7bdd0c49d4 --- /dev/null +++ b/.changeset/rich-dryers-lay.md @@ -0,0 +1,5 @@ +--- +"react-email": patch +--- + +Watch directories targeted by dynamic `import()` template literals so changes to runtime-resolved files trigger preview reloads. diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.spec.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.spec.ts index 4d965cfc4a..4df3ba14f9 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.spec.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.spec.ts @@ -163,7 +163,7 @@ import {} from './general-importing-file'; toAbsolute('file-b.ts'), toAbsolute('general-importing-file.ts'), ], - globDependencyPaths: [], + dynamicDependencyDirectories: [], moduleDependencies: [], } satisfies DependencyGraph[number]); expect(dependencyGraph[toAbsolute('file-a.ts')]?.dependentPaths).toContain( @@ -194,7 +194,7 @@ import {} from './file-b'; path: pathToTemporaryFile, dependentPaths: [], dependencyPaths: [toAbsolute('file-a.ts'), toAbsolute('file-b.ts')], - globDependencyPaths: [], + dynamicDependencyDirectories: [], moduleDependencies: [], } satisfies DependencyGraph[number]); expect(dependencyGraph[toAbsolute('file-a.ts')]?.dependentPaths).toContain( @@ -244,14 +244,26 @@ describe('createDependencyGraph() with dynamic imports', () => { './test/aliased-fallback-graph/src', ); - it('exposes the directory of a template-literal `import()` as a glob dependency and resolves runtime files to the importing module', async () => { - const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = + const collectDynamicDependencyDirectories = (graph: DependencyGraph) => { + const directories = new Set(); + for (const module of Object.values(graph)) { + for (const directory of module.dynamicDependencyDirectories) { + directories.add(directory); + } + } + return [...directories]; + }; + + it('exposes the directory of a template-literal `import()` as a dynamic dependency and resolves runtime files to the importing module', async () => { + const [graph, , { resolveDependentsOf }] = await createDependencyGraph(fixtureDirectory); const messagesDirectory = path.join(fixtureDirectory, 'messages'); const templatePath = path.join(fixtureDirectory, 'template.ts'); - expect(getGlobDependencyDirectories()).toEqual([messagesDirectory]); + expect(collectDynamicDependencyDirectories(graph)).toEqual([ + messagesDirectory, + ]); expect( resolveDependentsOf(path.join(messagesDirectory, 'en', 'common.json')), @@ -259,8 +271,9 @@ describe('createDependencyGraph() with dynamic imports', () => { }); it('falls through to a later alias candidate when the first does not exist on disk', async () => { - const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = - await createDependencyGraph(aliasedFallbackFixtureDirectory); + const [graph, , { resolveDependentsOf }] = await createDependencyGraph( + aliasedFallbackFixtureDirectory, + ); const localesDirectory = path.join( path.dirname(aliasedFallbackFixtureDirectory), @@ -272,7 +285,9 @@ describe('createDependencyGraph() with dynamic imports', () => { 'template.ts', ); - expect(getGlobDependencyDirectories()).toEqual([localesDirectory]); + expect(collectDynamicDependencyDirectories(graph)).toEqual([ + localesDirectory, + ]); expect( resolveDependentsOf(path.join(localesDirectory, 'de', 'common.json')), @@ -280,13 +295,15 @@ describe('createDependencyGraph() with dynamic imports', () => { }); it('resolves tsconfig path aliases in dynamic import template literals', async () => { - const [, , { resolveDependentsOf, getGlobDependencyDirectories }] = + const [graph, , { resolveDependentsOf }] = await createDependencyGraph(aliasedFixtureDirectory); const localesDirectory = path.join(aliasedFixtureDirectory, 'locales'); const templatePath = path.join(aliasedFixtureDirectory, 'template.ts'); - expect(getGlobDependencyDirectories()).toEqual([localesDirectory]); + expect(collectDynamicDependencyDirectories(graph)).toEqual([ + localesDirectory, + ]); expect( resolveDependentsOf(path.join(localesDirectory, 'tr', 'common.json')), diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.ts index d765afbc22..f5d180fca8 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.ts @@ -13,7 +13,7 @@ interface Module { dependencyPaths: string[]; dependentPaths: string[]; - globDependencyPaths: string[]; + dynamicDependencyDirectories: string[]; moduleDependencies: string[]; } @@ -83,18 +83,22 @@ export const isUnderDirectory = (filePath: string, directoryPath: string) => { return filePath.startsWith(prefix); }; -const resolveGlobPrefixToDirectory = ( +const resolveDynamicImportDirectory = ( prefix: string, - modulePath: string, + filePath: string, ): string | undefined => { - const moduleDirectory = path.dirname(modulePath); + const moduleDirectory = path.dirname(filePath); + const normalizedPrefix = path.normalize(prefix); + const endsWithSeparator = normalizedPrefix.endsWith(path.sep); + const trimmed = endsWithSeparator + ? normalizedPrefix.slice(0, -path.sep.length) + : normalizedPrefix; let resolvedPrefix: string; const isRelative = prefix.startsWith('.') || path.isAbsolute(prefix); if (isRelative) { - resolvedPrefix = path.resolve(moduleDirectory, prefix); + resolvedPrefix = path.resolve(moduleDirectory, normalizedPrefix); } else { - const trimmed = prefix.replace(/[/\\]+$/, ''); if (trimmed.length === 0) return undefined; const aliased = resolveAliasedDirectoryPrefix(trimmed, moduleDirectory); if (aliased === undefined) { @@ -103,10 +107,9 @@ const resolveGlobPrefixToDirectory = ( resolvedPrefix = aliased; } - const directory = - prefix.endsWith('/') || prefix.endsWith(path.sep) - ? resolvedPrefix - : path.dirname(resolvedPrefix); + const directory = endsWithSeparator + ? resolvedPrefix + : path.dirname(resolvedPrefix); if (isUnderDirectory(moduleDirectory, directory)) return undefined; @@ -138,7 +141,7 @@ export const createDependencyGraph = async (directory: string) => { path, dependencyPaths: [], dependentPaths: [], - globDependencyPaths: [], + dynamicDependencyDirectories: [], moduleDependencies: [], }, ]), @@ -148,7 +151,7 @@ export const createDependencyGraph = async (directory: string) => { const contents = await fs.readFile(filePath, 'utf8'); const imports = isJavascriptModule(filePath) ? getImportedModules(contents) - : { staticImports: [], dynamicGlobPrefixes: [] }; + : { staticImports: [], dynamicImportPrefixes: [] }; const importedPaths = isJavascriptModule(filePath) ? resolvePathAliases(imports.staticImports, path.dirname(filePath)) : []; @@ -237,10 +240,10 @@ export const createDependencyGraph = async (directory: string) => { dependencyPath.startsWith('.') || path.isAbsolute(dependencyPath), ); - const globDependencyPaths = Array.from( + const dynamicDependencyDirectories = Array.from( new Set( - imports.dynamicGlobPrefixes - .map((prefix) => resolveGlobPrefixToDirectory(prefix, filePath)) + imports.dynamicImportPrefixes + .map((prefix) => resolveDynamicImportDirectory(prefix, filePath)) .filter((d): d is string => typeof d === 'string'), ), ); @@ -248,7 +251,7 @@ export const createDependencyGraph = async (directory: string) => { return { dependencyPaths: nonNodeModuleImportPathsRelativeToDirectory, moduleDependencies, - globDependencyPaths, + dynamicDependencyDirectories, }; }; @@ -258,7 +261,7 @@ export const createDependencyGraph = async (directory: string) => { path: moduleFilePath, dependencyPaths: [], dependentPaths: [], - globDependencyPaths: [], + dynamicDependencyDirectories: [], moduleDependencies: [], }; } @@ -266,11 +269,12 @@ export const createDependencyGraph = async (directory: string) => { const { moduleDependencies, dependencyPaths: newDependencyPaths, - globDependencyPaths: newGlobDependencyPaths, + dynamicDependencyDirectories: newDynamicDependencyDirectories, } = await getDependencyPaths(moduleFilePath); graph[moduleFilePath].moduleDependencies = moduleDependencies; - graph[moduleFilePath].globDependencyPaths = newGlobDependencyPaths; + graph[moduleFilePath].dynamicDependencyDirectories = + newDynamicDependencyDirectories; // we go through these to remove the ones that don't exist anymore for (const dependencyPath of graph[moduleFilePath].dependencyPaths) { @@ -332,21 +336,6 @@ export const createDependencyGraph = async (directory: string) => { } }; - /** - * Returns the union of all directories any module declared as a glob - * dependency. Useful for telling chokidar what to watch on top of the static - * dependency graph. - */ - const getGlobDependencyDirectories = (): string[] => { - const directories = new Set(); - for (const module of Object.values(graph)) { - for (const directory of module.globDependencyPaths) { - directories.add(directory); - } - } - return [...directories]; - }; - return [ graph, async (event: EventName, pathToModified: string) => { @@ -393,9 +382,9 @@ export const createDependencyGraph = async (directory: string) => { * Resolves all modules that depend on the specified module, directly or indirectly. * * If the path doesn't correspond to a graph node (e.g. a JSON file - * loaded via dynamic `import(\`...\`)`), modules whose glob directories - * contain the path are treated as direct dependents and their own - * dependents are resolved transitively. + * loaded via dynamic `import(\`...\`)`), modules whose dynamic-import + * directories contain the path are treated as direct dependents and + * their own dependents are resolved transitively. * * @param pathToModule - The path to the module whose dependents we want to find * @returns An array of paths to all modules that depend on the specified module @@ -407,9 +396,9 @@ export const createDependencyGraph = async (directory: string) => { const stack: string[] = [pathToModule]; for (const module of Object.values(graph)) { - for (const globDirectory of module.globDependencyPaths) { + for (const directory of module.dynamicDependencyDirectories) { if ( - isUnderDirectory(pathToModule, globDirectory) && + isUnderDirectory(pathToModule, directory) && module.path !== pathToModule && !dependentPaths.has(module.path) ) { @@ -439,8 +428,6 @@ export const createDependencyGraph = async (directory: string) => { return [...dependentPaths.values()]; }, - - getGlobDependencyDirectories, }, ] as const; }; diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.spec.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.spec.ts index 20684961e6..06fab100ef 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.spec.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.spec.ts @@ -12,7 +12,7 @@ describe('getImportedModules()', () => { expect(getImportedModules(contents)).toEqual({ staticImports: ['node:fs', './get-imported-modules.js'], - dynamicGlobPrefixes: [], + dynamicImportPrefixes: [], }); }); @@ -24,7 +24,7 @@ describe('getImportedModules()', () => { export { ComponentC }`; expect(getImportedModules(contents)).toEqual({ staticImports: ['./component-a', './component-b', './component-c'], - dynamicGlobPrefixes: [], + dynamicImportPrefixes: [], }); }); @@ -57,7 +57,7 @@ import * as React from "react"; '../../my-component', 'react', ], - dynamicGlobPrefixes: [], + dynamicImportPrefixes: [], }); }); @@ -90,7 +90,7 @@ import * as React from 'react'; '../../my-component', 'react', ], - dynamicGlobPrefixes: [], + dynamicImportPrefixes: [], }); }); @@ -123,7 +123,7 @@ const React = require("react"); '../../my-component', 'react', ], - dynamicGlobPrefixes: [], + dynamicImportPrefixes: [], }); }); @@ -156,7 +156,7 @@ const React = require('react'); '../../my-component', 'react', ], - dynamicGlobPrefixes: [], + dynamicImportPrefixes: [], }); }); @@ -164,7 +164,7 @@ const React = require('react'); const contents = `const mod = await import('./my-module.json');`; expect(getImportedModules(contents)).toEqual({ staticImports: ['./my-module.json'], - dynamicGlobPrefixes: [], + dynamicImportPrefixes: [], }); }); @@ -178,7 +178,7 @@ const React = require('react'); `; expect(getImportedModules(contents)).toEqual({ staticImports: [], - dynamicGlobPrefixes: ['./messages/'], + dynamicImportPrefixes: ['./messages/'], }); }); @@ -187,7 +187,7 @@ const React = require('react'); const contents = 'const m = await import(`${base}/file.json`);'; expect(getImportedModules(contents)).toEqual({ staticImports: [], - dynamicGlobPrefixes: [], + dynamicImportPrefixes: [], }); }); @@ -195,7 +195,7 @@ const React = require('react'); const contents = 'const m = await import(`./my-module.json`);'; expect(getImportedModules(contents)).toEqual({ staticImports: ['./my-module.json'], - dynamicGlobPrefixes: [], + dynamicImportPrefixes: [], }); }); }); diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.ts index 9f2d5c619c..77eee631ae 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/get-imported-modules.ts @@ -10,12 +10,12 @@ const traverse = export interface ImportedModules { staticImports: string[]; - dynamicGlobPrefixes: string[]; + dynamicImportPrefixes: string[]; } export const getImportedModules = (contents: string): ImportedModules => { const staticImports: string[] = []; - const dynamicGlobPrefixes: string[] = []; + const dynamicImportPrefixes: string[] = []; const parsedContents = parse(contents, { sourceType: 'unambiguous', strictMode: false, @@ -67,12 +67,12 @@ export const getImportedModules = (contents: string): ImportedModules => { const firstQuasi = argument.quasis[0]!; const leadingStatic = firstQuasi.value.cooked ?? firstQuasi.value.raw; if (leadingStatic.length > 0) { - dynamicGlobPrefixes.push(leadingStatic); + dynamicImportPrefixes.push(leadingStatic); } } } }, }); - return { staticImports, dynamicGlobPrefixes }; + return { staticImports, dynamicImportPrefixes }; }; diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.ts index d05235005c..04f776e6ef 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/setup-hot-reloading.ts @@ -47,11 +47,8 @@ export const setupHotreloading = async ( emailDirRelativePath, ); - const [ - dependencyGraph, - updateDependencyGraph, - { resolveDependentsOf, getGlobDependencyDirectories }, - ] = await createDependencyGraph(absolutePathToEmailsDirectory); + const [dependencyGraph, updateDependencyGraph, { resolveDependentsOf }] = + await createDependencyGraph(absolutePathToEmailsDirectory); const watcher = watch('', { ignoreInitial: true, @@ -72,8 +69,17 @@ export const setupHotreloading = async ( // Directories targeted by dynamic `import(\`./prefix/${expr}\`)` calls. // These files are resolved at runtime so they never appear in the static // dependency graph; we still want their changes to refresh the preview. - let watchedGlobDirectories: string[] = getGlobDependencyDirectories(); - for (const directory of watchedGlobDirectories) { + const getDynamicDependencyDirectories = () => { + const directories = new Set(); + for (const module of Object.values(dependencyGraph)) { + for (const directory of module.dynamicDependencyDirectories) { + directories.add(directory); + } + } + return [...directories]; + }; + let dynamicDependencyDirectories = getDynamicDependencyDirectories(); + for (const directory of dynamicDependencyDirectories) { watcher.add(directory); } @@ -112,18 +118,18 @@ export const setupHotreloading = async ( } filesOutsideEmailsDirectory = newFilesOutsideEmailsDirectory; - const newWatchedGlobDirectories = getGlobDependencyDirectories(); - for (const directory of watchedGlobDirectories) { - if (!newWatchedGlobDirectories.includes(directory)) { + const newDynamicDependencyDirectories = getDynamicDependencyDirectories(); + for (const directory of dynamicDependencyDirectories) { + if (!newDynamicDependencyDirectories.includes(directory)) { watcher.unwatch(directory); } } - for (const directory of newWatchedGlobDirectories) { - if (!watchedGlobDirectories.includes(directory)) { + for (const directory of newDynamicDependencyDirectories) { + if (!dynamicDependencyDirectories.includes(directory)) { watcher.add(directory); } } - watchedGlobDirectories = newWatchedGlobDirectories; + dynamicDependencyDirectories = newDynamicDependencyDirectories; changes.push({ event, From f6845b5c768329beca6f2d2d6fcbc131bdfd799c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20Karakanl=C4=B1?= <102619516+actuallyzefe@users.noreply.github.com> Date: Wed, 29 Apr 2026 02:37:06 +0300 Subject: [PATCH 11/12] refactor(react-email): apply lint --- .../preview/hot-reloading/create-dependency-graph.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.spec.ts b/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.spec.ts index 4df3ba14f9..4ee9e0af27 100644 --- a/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.spec.ts +++ b/packages/react-email/src/cli/utils/preview/hot-reloading/create-dependency-graph.spec.ts @@ -295,8 +295,9 @@ describe('createDependencyGraph() with dynamic imports', () => { }); it('resolves tsconfig path aliases in dynamic import template literals', async () => { - const [graph, , { resolveDependentsOf }] = - await createDependencyGraph(aliasedFixtureDirectory); + const [graph, , { resolveDependentsOf }] = await createDependencyGraph( + aliasedFixtureDirectory, + ); const localesDirectory = path.join(aliasedFixtureDirectory, 'locales'); const templatePath = path.join(aliasedFixtureDirectory, 'template.ts'); From b4b7c083d68f8090cd40be36b49c95d57c93e3e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20Karakanl=C4=B1?= <102619516+actuallyzefe@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:38:38 +0300 Subject: [PATCH 12/12] chore: trigger CI