fix(preview-server): compatibility checker can't find config when inlining pixel based preset (#2351)

This commit is contained in:
Gabriel Miranda
2025-07-11 15:35:04 -03:00
committed by GitHub
parent 5eb2257732
commit 09d7d9db62
5 changed files with 114 additions and 64 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@react-email/preview-server": patch
---
improved method of resolving tailwind configs when checking compatibility
@@ -0,0 +1,44 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { parse } from '@babel/parser';
import { pixelBasedPreset } from '@react-email/components';
import { getTailwindConfig } from './get-tailwind-config';
describe('getTailwindConfig()', () => {
it("should work on the demo's Vercel Invite template", async () => {
const sourcePath = path.resolve(
__dirname,
'../../../../../../apps/demo/emails/notifications/vercel-invite-user.tsx',
);
const sourceCode = await fs.readFile(sourcePath, 'utf8');
const ast = parse(sourceCode, {
strictMode: false,
errorRecovery: true,
sourceType: 'unambiguous',
plugins: ['jsx', 'typescript', 'decorators'],
});
expect(await getTailwindConfig(sourceCode, ast, sourcePath)).toEqual({
presets: [pixelBasedPreset],
});
});
it('should work with email templates that import the tailwind config', async () => {
const sourcePath = path.resolve(
__dirname,
'./tests/dummy-email-template.tsx',
);
const sourceCode = await fs.readFile(sourcePath, 'utf8');
const ast = parse(sourceCode, {
strictMode: false,
errorRecovery: true,
sourceType: 'unambiguous',
plugins: ['jsx', 'typescript', 'decorators'],
});
expect(await getTailwindConfig(sourceCode, ast, sourcePath)).toEqual({
theme: {},
presets: [pixelBasedPreset],
});
});
});
@@ -2,8 +2,10 @@ import path from 'node:path';
import type { Node } from '@babel/traverse';
import traverse from '@babel/traverse';
import * as esbuild from 'esbuild';
import type { RawSourceMap } from 'source-map-js';
import type { Config as TailwindOriginalConfig } from 'tailwindcss';
import type { AST } from '../../../actions/email-validation/check-compatibility';
import { improveErrorWithSourceMap } from '../../improve-error-with-sourcemap';
import { isErr } from '../../result';
import { runBundledCode } from '../../run-bundled-code';
@@ -23,8 +25,6 @@ export type TailwindConfig = Pick<
| 'plugins'
>;
type ImportDeclaration = Node & { type: 'ImportDeclaration' };
export const getTailwindConfig = async (
sourceCode: string,
ast: AST,
@@ -33,35 +33,23 @@ export const getTailwindConfig = async (
const configAttribute = getTailwindConfigNode(ast);
if (configAttribute) {
const configIdentifierName =
configAttribute.value?.type === 'JSXExpressionContainer' &&
configAttribute.value.expression.type === 'Identifier'
? configAttribute.value.expression.name
: undefined;
if (configIdentifierName) {
const tailwindConfigImport = getImportWithGivenDefaultSpecifier(
ast,
configIdentifierName,
);
if (tailwindConfigImport) {
return getConfigFromImport(tailwindConfigImport, sourcePath);
}
}
const configObjectExpression =
configAttribute.value?.type === 'JSXExpressionContainer' &&
configAttribute.value.expression.type === 'ObjectExpression'
const configExpressionValue =
configAttribute.value?.type === 'JSXExpressionContainer'
? configAttribute.value.expression
: undefined;
if (configObjectExpression?.start && configObjectExpression.end) {
const configObjectSourceCode = sourceCode.slice(
configObjectExpression.start,
configObjectExpression.end,
if (configExpressionValue?.start && configExpressionValue.end) {
const configSourceValue = sourceCode.slice(
configExpressionValue.start,
configExpressionValue.end,
);
try {
const getConfig = new Function(`return ${configObjectSourceCode}`);
return getConfig() as TailwindConfig;
return getConfigFromCode(
`${sourceCode}
const reactEmailTailwindConfigInternal = ${configSourceValue};`,
sourcePath,
);
} catch (exception) {
console.warn(exception);
console.warn(
@@ -74,80 +62,77 @@ export const getTailwindConfig = async (
return {};
};
const getConfigFromImport = async (
tailwindConfigImport: ImportDeclaration,
sourcePath: string,
const getConfigFromCode = async (
code: string,
filepath: string,
): Promise<TailwindConfig> => {
const configRelativePath = tailwindConfigImport.source.value;
const sourceDirpath = path.dirname(sourcePath);
const configFilepath = path.join(sourceDirpath, configRelativePath);
const configDirpath = path.dirname(filepath);
const configBuildResult = await esbuild.build({
bundle: true,
stdin: {
contents: `import tailwindConfig from "${configRelativePath}";
export { tailwindConfig };`,
contents: `${code}
export { reactEmailTailwindConfigInternal };`,
sourcefile: filepath,
loader: 'tsx',
resolveDir: path.dirname(sourcePath),
resolveDir: configDirpath,
},
platform: 'node',
sourcemap: 'external',
jsx: 'automatic',
outdir: 'stdout', // just a stub for esbuild, it won't actually write to this folder
write: false,
format: 'cjs',
logLevel: 'silent',
});
const configFile = configBuildResult.outputFiles[0];
const sourceMapFile = configBuildResult.outputFiles[0]!;
const configFile = configBuildResult.outputFiles[1];
if (configFile === undefined) {
throw new Error(
'Could not build config file as it was found as undefined, this is most likely a bug, please open an issue.',
);
}
const configModule = runBundledCode(configFile.text, configFilepath);
const configModule = runBundledCode(configFile.text, filepath);
if (isErr(configModule)) {
throw new Error(
`Error when trying to run the config file: ${configModule.error}`,
const sourceMap = JSON.parse(sourceMapFile.text) as RawSourceMap;
// because it will have a path like <tsconfigLocation>/stdout/email.js.map
sourceMap.sourceRoot = path.resolve(sourceMapFile.path, '../..');
sourceMap.sources = sourceMap.sources.map((source) =>
path.resolve(sourceMapFile.path, '..', source),
);
const errorObject = improveErrorWithSourceMap(
configModule.error as Error,
filepath,
sourceMap,
);
const error = new Error();
error.name = errorObject.name;
error.message = errorObject.message;
error.stack = errorObject.stack;
throw error;
}
if (
typeof configModule.value === 'object' &&
configModule.value !== null &&
'tailwindConfig' in configModule.value
'reactEmailTailwindConfigInternal' in configModule.value
) {
return configModule.value.tailwindConfig as TailwindConfig;
return configModule.value
.reactEmailTailwindConfigInternal as TailwindConfig;
}
throw new Error(
`Could not read Tailwind config at ${configFilepath} because it doesn't have a default export in it.`,
'Could not get the Tailwind config, this is likely a bug, please file an issue.',
{
cause: {
configModule,
configFilepath,
configFilepath: filepath,
},
},
);
};
const getImportWithGivenDefaultSpecifier = (
ast: AST,
specifierName: string,
) => {
let importNode: ImportDeclaration | undefined;
traverse(ast, {
ImportDeclaration(nodePath) {
if (
nodePath.node.specifiers.some(
(specifier) =>
specifier.type === 'ImportDefaultSpecifier' &&
specifier.local.name === specifierName,
)
) {
importNode = nodePath.node;
}
},
});
return importNode;
};
type JSXAttribute = Node & { type: 'JSXAttribute' };
const getTailwindConfigNode = (ast: AST) => {
@@ -0,0 +1,10 @@
import { Tailwind } from '@react-email/components';
import tailwindConfig from './tailwind.config';
export default function EmailTemplate() {
return (
<Tailwind config={tailwindConfig}>
<div className="bg-red-400 w-20" />
</Tailwind>
);
}
@@ -0,0 +1,6 @@
import { pixelBasedPreset, type TailwindConfig } from '@react-email/components';
export default {
theme: {},
presets: [pixelBasedPreset],
} satisfies TailwindConfig;