Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ceaa3aa
fix(cli): add --watch option for dynamic file watching in dev server
actuallyzefe Apr 25, 2026
06408a4
docs(readme): update documentation for --watch option in react-email CLI
actuallyzefe Apr 25, 2026
4bd5cb5
chore: lint fix
actuallyzefe Apr 25, 2026
ed03357
Merge branch 'canary' into fix/3412-email-dev-not-watching-messages-f…
actuallyzefe Apr 27, 2026
b8b3cde
refactor(cli): remove --watch option and enhance dynamic import handling
actuallyzefe Apr 27, 2026
3c7de6f
ci: re-trigger workflow
actuallyzefe Apr 27, 2026
47648e6
fix(cli): enhance dynamic import handling with tsconfig path alias re…
actuallyzefe Apr 27, 2026
77c2364
fix(cli): correct alias fallback in dynamic-import directory resolution
actuallyzefe Apr 27, 2026
723087b
remove overly verbose comments from hot-reloading modules
actuallyzefe Apr 28, 2026
5584ee9
test(cli): merge dynamic-import-graph specs into create-dependency-graph
actuallyzefe Apr 28, 2026
f1a25e3
Merge branch 'canary' into fix/3412-email-dev-not-watching-messages-f…
actuallyzefe Apr 28, 2026
275dbff
Merge remote-tracking branch 'refs/remotes/origin/fix/3412-email-dev-…
actuallyzefe Apr 28, 2026
b2dccfc
Merge branch 'canary' into fix/3412-email-dev-not-watching-messages-f…
actuallyzefe Apr 28, 2026
0a2999c
refactor(react-email): rename globDependencyPaths to dynamicDependenc…
actuallyzefe Apr 28, 2026
f6845b5
refactor(react-email): apply lint
actuallyzefe Apr 28, 2026
1ae77c4
Merge branch 'canary' into fix/3412-email-dev-not-watching-messages-f…
actuallyzefe Apr 29, 2026
2eb9949
Merge branch 'canary' into fix/3412-email-dev-not-watching-messages-f…
actuallyzefe Apr 29, 2026
b52760e
Merge branch 'canary' into fix/3412-email-dev-not-watching-messages-f…
actuallyzefe Apr 30, 2026
b4b7c08
chore: trigger CI
actuallyzefe Apr 30, 2026
d0109cf
Merge branch 'canary' into fix/3412-email-dev-not-watching-messages-f…
actuallyzefe May 3, 2026
548a3c2
Merge branch 'canary' into fix/3412-email-dev-not-watching-messages-f…
actuallyzefe May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rich-dryers-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-email": patch
---

Watch directories targeted by dynamic `import()` template literals so changes to runtime-resolved files trigger preview reloads.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'node:path';
import {
createDependencyGraph,
type DependencyGraph,
isUnderDirectory,
} from './create-dependency-graph.js';

const testingDiretctory = path.join(
Expand Down Expand Up @@ -162,6 +163,7 @@ import {} from './general-importing-file';
toAbsolute('file-b.ts'),
toAbsolute('general-importing-file.ts'),
],
dynamicDependencyDirectories: [],
moduleDependencies: [],
} satisfies DependencyGraph[number]);
expect(dependencyGraph[toAbsolute('file-a.ts')]?.dependentPaths).toContain(
Expand Down Expand Up @@ -192,6 +194,7 @@ import {} from './file-b';
path: pathToTemporaryFile,
dependentPaths: [],
dependencyPaths: [toAbsolute('file-a.ts'), toAbsolute('file-b.ts')],
dynamicDependencyDirectories: [],
moduleDependencies: [],
} satisfies DependencyGraph[number]);
expect(dependencyGraph[toAbsolute('file-a.ts')]?.dependentPaths).toContain(
Expand Down Expand Up @@ -224,3 +227,121 @@ 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',
);

const collectDynamicDependencyDirectories = (graph: DependencyGraph) => {
const directories = new Set<string>();
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(collectDynamicDependencyDirectories(graph)).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 [graph, , { resolveDependentsOf }] = await createDependencyGraph(
aliasedFallbackFixtureDirectory,
);

const localesDirectory = path.join(
path.dirname(aliasedFallbackFixtureDirectory),
'lib',
'locales',
);
const templatePath = path.join(
aliasedFallbackFixtureDirectory,
'template.ts',
);

expect(collectDynamicDependencyDirectories(graph)).toEqual([
localesDirectory,
]);

expect(
resolveDependentsOf(path.join(localesDirectory, 'de', 'common.json')),
).toEqual([templatePath]);
});

it('resolves tsconfig path aliases in dynamic import template literals', async () => {
const [graph, , { resolveDependentsOf }] = await createDependencyGraph(
aliasedFixtureDirectory,
);

const localesDirectory = path.join(aliasedFixtureDirectory, 'locales');
const templatePath = path.join(aliasedFixtureDirectory, 'template.ts');

expect(collectDynamicDependencyDirectories(graph)).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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@ 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;

dependencyPaths: string[];
dependentPaths: string[];

dynamicDependencyDirectories: string[];

moduleDependencies: string[];
}

Expand Down Expand Up @@ -70,6 +75,54 @@ const checkFileExtensionsUntilItExists = (
}
};

export const isUnderDirectory = (filePath: string, directoryPath: string) => {
if (filePath === directoryPath) return true;
const prefix = directoryPath.endsWith(path.sep)
? directoryPath
: directoryPath + path.sep;
return filePath.startsWith(prefix);
};

const resolveDynamicImportDirectory = (
prefix: string,
filePath: string,
): string | undefined => {
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, normalizedPrefix);
} else {
if (trimmed.length === 0) return undefined;
const aliased = resolveAliasedDirectoryPrefix(trimmed, moduleDirectory);
if (aliased === undefined) {
return undefined;
}
resolvedPrefix = aliased;
}

const directory = endsWithSeparator
? resolvedPrefix
: path.dirname(resolvedPrefix);

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.
Expand All @@ -88,15 +141,19 @@ export const createDependencyGraph = async (directory: string) => {
path,
dependencyPaths: [],
dependentPaths: [],
dynamicDependencyDirectories: [],
moduleDependencies: [],
},
]),
);

const getDependencyPaths = async (filePath: string) => {
const contents = await fs.readFile(filePath, 'utf8');
const imports = isJavascriptModule(filePath)
? getImportedModules(contents)
: { staticImports: [], dynamicImportPrefixes: [] };
const importedPaths = isJavascriptModule(filePath)
? resolvePathAliases(getImportedModules(contents), path.dirname(filePath))
? resolvePathAliases(imports.staticImports, path.dirname(filePath))
: [];
const importedPathsRelativeToDirectory = importedPaths.map(
(dependencyPath) => {
Expand All @@ -115,7 +172,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
*/
Expand Down Expand Up @@ -183,9 +240,18 @@ export const createDependencyGraph = async (directory: string) => {
dependencyPath.startsWith('.') || path.isAbsolute(dependencyPath),
);

const dynamicDependencyDirectories = Array.from(
new Set(
imports.dynamicImportPrefixes
.map((prefix) => resolveDynamicImportDirectory(prefix, filePath))
.filter((d): d is string => typeof d === 'string'),
),
);

return {
dependencyPaths: nonNodeModuleImportPathsRelativeToDirectory,
moduleDependencies,
dynamicDependencyDirectories,
};
};

Expand All @@ -195,14 +261,20 @@ export const createDependencyGraph = async (directory: string) => {
path: moduleFilePath,
dependencyPaths: [],
dependentPaths: [],
dynamicDependencyDirectories: [],
moduleDependencies: [],
};
}

const { moduleDependencies, dependencyPaths: newDependencyPaths } =
await getDependencyPaths(moduleFilePath);
const {
moduleDependencies,
dependencyPaths: newDependencyPaths,
dynamicDependencyDirectories: newDynamicDependencyDirectories,
} = await getDependencyPaths(moduleFilePath);

graph[moduleFilePath].moduleDependencies = moduleDependencies;
graph[moduleFilePath].dynamicDependencyDirectories =
newDynamicDependencyDirectories;

// we go through these to remove the ones that don't exist anymore
for (const dependencyPath of graph[moduleFilePath].dependencyPaths) {
Expand Down Expand Up @@ -309,6 +381,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 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
*/
Expand All @@ -318,6 +395,19 @@ export const createDependencyGraph = async (directory: string) => {
const dependentPaths = new Set<string>();
const stack: string[] = [pathToModule];

for (const module of Object.values(graph)) {
for (const directory of module.dynamicDependencyDirectories) {
if (
isUnderDirectory(pathToModule, directory) &&
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];
Expand Down
Loading
Loading