From c6fcd94012ad940f37a3709b9ceea93fdde19fe4 Mon Sep 17 00:00:00 2001 From: Gabriel Miranda Date: Thu, 9 Jan 2025 14:21:13 -0300 Subject: [PATCH] fix(react-email): Preview server crashing without React 19 (#1861) --- .changeset/honest-apes-hope.md | 5 + .../get-emails-directory-metadata-action.ts | 19 +++ .../actions/get-emails-directory-metadata.ts | 123 ------------------ packages/react-email/src/app/layout.tsx | 2 +- .../src/app/preview/[...slug]/page.tsx | 2 +- .../react-email/src/cli/commands/build.ts | 2 +- .../react-email/src/cli/commands/export.ts | 2 +- .../sidebar/sidebar-directory-children.tsx | 2 +- .../components/sidebar/sidebar-directory.tsx | 2 +- packages/react-email/src/contexts/emails.tsx | 8 +- .../get-emails-directory-metadata.spec.ts | 2 +- .../utils/get-emails-directory-metadata.ts | 119 +++++++++++++++++ 12 files changed, 153 insertions(+), 135 deletions(-) create mode 100644 .changeset/honest-apes-hope.md create mode 100644 packages/react-email/src/actions/get-emails-directory-metadata-action.ts delete mode 100644 packages/react-email/src/actions/get-emails-directory-metadata.ts rename packages/react-email/src/{actions => utils}/get-emails-directory-metadata.spec.ts (98%) create mode 100644 packages/react-email/src/utils/get-emails-directory-metadata.ts diff --git a/.changeset/honest-apes-hope.md b/.changeset/honest-apes-hope.md new file mode 100644 index 0000000000..56215a3e9b --- /dev/null +++ b/.changeset/honest-apes-hope.md @@ -0,0 +1,5 @@ +--- +"react-email": patch +--- + +Fix preview server crashing without React 19 diff --git a/packages/react-email/src/actions/get-emails-directory-metadata-action.ts b/packages/react-email/src/actions/get-emails-directory-metadata-action.ts new file mode 100644 index 0000000000..e1293e8407 --- /dev/null +++ b/packages/react-email/src/actions/get-emails-directory-metadata-action.ts @@ -0,0 +1,19 @@ +'use server'; + +import type { EmailsDirectory } from '../utils/get-emails-directory-metadata'; +import { getEmailsDirectoryMetadata } from '../utils/get-emails-directory-metadata'; + +export const getEmailsDirectoryMetadataAction = async ( + absolutePathToEmailsDirectory: string, + keepFileExtensions = false, + isSubDirectory = false, + + baseDirectoryPath = absolutePathToEmailsDirectory, +): Promise => { + return getEmailsDirectoryMetadata( + absolutePathToEmailsDirectory, + keepFileExtensions, + isSubDirectory, + baseDirectoryPath, + ); +}; diff --git a/packages/react-email/src/actions/get-emails-directory-metadata.ts b/packages/react-email/src/actions/get-emails-directory-metadata.ts deleted file mode 100644 index f11cdbc77e..0000000000 --- a/packages/react-email/src/actions/get-emails-directory-metadata.ts +++ /dev/null @@ -1,123 +0,0 @@ -'use server'; -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -import fs from 'node:fs'; -import path from 'node:path'; -import { cache } from 'react'; - -const isFileAnEmail = (fullPath: string): boolean => { - const stat = fs.statSync(fullPath); - - if (stat.isDirectory()) return false; - - const { ext } = path.parse(fullPath); - - if (!['.js', '.tsx', '.jsx'].includes(ext)) return false; - - // This is to avoid a possible race condition where the file doesn't exist anymore - // once we are checking if it is an actual email, this couuld cause issues that - // would be very hard to debug and find out the why of it happening. - if (!fs.existsSync(fullPath)) { - return false; - } - - // check with a heuristic to see if the file has at least - // a default export - const fileContents = fs.readFileSync(fullPath, 'utf8'); - - return /\bexport\s+default\b/gm.test(fileContents); -}; - -export interface EmailsDirectory { - absolutePath: string; - relativePath: string; - directoryName: string; - emailFilenames: string[]; - subDirectories: EmailsDirectory[]; -} - -const mergeDirectoriesWithSubDirectories = ( - emailsDirectoryMetadata: EmailsDirectory, -): EmailsDirectory => { - let currentResultingMergedDirectory: EmailsDirectory = - emailsDirectoryMetadata; - - while ( - currentResultingMergedDirectory.emailFilenames.length === 0 && - currentResultingMergedDirectory.subDirectories.length === 1 - ) { - const onlySubDirectory = currentResultingMergedDirectory.subDirectories[0]!; - currentResultingMergedDirectory = { - ...onlySubDirectory, - directoryName: path.join( - currentResultingMergedDirectory.directoryName, - onlySubDirectory.directoryName, - ), - }; - } - - return currentResultingMergedDirectory; -}; - -export const getEmailsDirectoryMetadata = cache( - async ( - absolutePathToEmailsDirectory: string, - keepFileExtensions = false, - isSubDirectory = false, - - baseDirectoryPath = absolutePathToEmailsDirectory, - ): Promise => { - if (!fs.existsSync(absolutePathToEmailsDirectory)) return; - - const dirents = await fs.promises.readdir(absolutePathToEmailsDirectory, { - withFileTypes: true, - }); - - const emailFilenames = dirents - .filter((dirent) => - isFileAnEmail(path.join(absolutePathToEmailsDirectory, dirent.name)), - ) - .map((dirent) => - keepFileExtensions - ? dirent.name - : dirent.name.replace(path.extname(dirent.name), ''), - ); - - const subDirectories = await Promise.all( - dirents - .filter( - (dirent) => - dirent.isDirectory() && - !dirent.name.startsWith('_') && - dirent.name !== 'static', - ) - .map((dirent) => { - const direntAbsolutePath = path.join( - absolutePathToEmailsDirectory, - dirent.name, - ); - - return getEmailsDirectoryMetadata( - direntAbsolutePath, - keepFileExtensions, - true, - baseDirectoryPath, - ) as Promise; - }), - ); - - const emailsMetadata = { - absolutePath: absolutePathToEmailsDirectory, - relativePath: path.relative( - baseDirectoryPath, - absolutePathToEmailsDirectory, - ), - directoryName: absolutePathToEmailsDirectory.split(path.sep).pop()!, - emailFilenames, - subDirectories, - } satisfies EmailsDirectory; - - return isSubDirectory - ? mergeDirectoriesWithSubDirectories(emailsMetadata) - : emailsMetadata; - }, -); diff --git a/packages/react-email/src/app/layout.tsx b/packages/react-email/src/app/layout.tsx index f9a0486b0b..9f2c1a16c4 100644 --- a/packages/react-email/src/app/layout.tsx +++ b/packages/react-email/src/app/layout.tsx @@ -1,8 +1,8 @@ import type { Metadata } from 'next'; import './globals.css'; -import { getEmailsDirectoryMetadata } from '../actions/get-emails-directory-metadata'; import { emailsDirectoryAbsolutePath } from '../utils/emails-directory-absolute-path'; import { EmailsProvider } from '../contexts/emails'; +import { getEmailsDirectoryMetadata } from '../utils/get-emails-directory-metadata'; import { inter } from './inter'; export const metadata: Metadata = { diff --git a/packages/react-email/src/app/preview/[...slug]/page.tsx b/packages/react-email/src/app/preview/[...slug]/page.tsx index 39fafefa07..299a7eef52 100644 --- a/packages/react-email/src/app/preview/[...slug]/page.tsx +++ b/packages/react-email/src/app/preview/[...slug]/page.tsx @@ -2,10 +2,10 @@ import path from 'node:path'; import { Suspense } from 'react'; import { redirect } from 'next/navigation'; import { getEmailPathFromSlug } from '../../../actions/get-email-path-from-slug'; -import { getEmailsDirectoryMetadata } from '../../../actions/get-emails-directory-metadata'; import { renderEmailByPath } from '../../../actions/render-email-by-path'; import { emailsDirectoryAbsolutePath } from '../../../utils/emails-directory-absolute-path'; import Home from '../../page'; +import { getEmailsDirectoryMetadata } from '../../../utils/get-emails-directory-metadata'; import Preview from './preview'; export const dynamicParams = true; diff --git a/packages/react-email/src/cli/commands/build.ts b/packages/react-email/src/cli/commands/build.ts index eff2fd8cd1..58ae23352d 100644 --- a/packages/react-email/src/cli/commands/build.ts +++ b/packages/react-email/src/cli/commands/build.ts @@ -5,7 +5,7 @@ import { spawn } from 'node:child_process'; import { type EmailsDirectory, getEmailsDirectoryMetadata, -} from '../../actions/get-emails-directory-metadata'; +} from '../../utils/get-emails-directory-metadata'; import { cliPacakgeLocation } from '../utils'; import { registerSpinnerAutostopping } from '../../utils/register-spinner-autostopping'; import logSymbols from 'log-symbols'; diff --git a/packages/react-email/src/cli/commands/export.ts b/packages/react-email/src/cli/commands/export.ts index 6f49aac137..d970832e67 100644 --- a/packages/react-email/src/cli/commands/export.ts +++ b/packages/react-email/src/cli/commands/export.ts @@ -12,7 +12,7 @@ import { tree } from '../utils'; import { EmailsDirectory, getEmailsDirectoryMetadata, -} from '../../actions/get-emails-directory-metadata'; +} from '../../utils/get-emails-directory-metadata'; import { renderingUtilitiesExporter } from '../../utils/esbuild/renderring-utilities-exporter'; const getEmailTemplatesFromDirectory = (emailDirectory: EmailsDirectory) => { diff --git a/packages/react-email/src/components/sidebar/sidebar-directory-children.tsx b/packages/react-email/src/components/sidebar/sidebar-directory-children.tsx index 831882db5e..572b7f1dba 100644 --- a/packages/react-email/src/components/sidebar/sidebar-directory-children.tsx +++ b/packages/react-email/src/components/sidebar/sidebar-directory-children.tsx @@ -2,7 +2,7 @@ import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'; import * as Collapsible from '@radix-ui/react-collapsible'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; -import type { EmailsDirectory } from '../../actions/get-emails-directory-metadata'; +import type { EmailsDirectory } from '../../utils/get-emails-directory-metadata'; import { emailsDirectoryAbsolutePath } from '../../utils/emails-directory-absolute-path'; import { cn } from '../../utils'; import { IconFile } from '../icons/icon-file'; diff --git a/packages/react-email/src/components/sidebar/sidebar-directory.tsx b/packages/react-email/src/components/sidebar/sidebar-directory.tsx index 05efaf68e5..971fe6b9bc 100644 --- a/packages/react-email/src/components/sidebar/sidebar-directory.tsx +++ b/packages/react-email/src/components/sidebar/sidebar-directory.tsx @@ -2,7 +2,7 @@ import * as Collapsible from '@radix-ui/react-collapsible'; import * as React from 'react'; import { cn } from '../../utils'; -import { type EmailsDirectory } from '../../actions/get-emails-directory-metadata'; +import { type EmailsDirectory } from '../../utils/get-emails-directory-metadata'; import { Heading } from '../heading'; import { IconFolder } from '../icons/icon-folder'; import { IconFolderOpen } from '../icons/icon-folder-open'; diff --git a/packages/react-email/src/contexts/emails.tsx b/packages/react-email/src/contexts/emails.tsx index 4ccc3b8160..ffee734691 100644 --- a/packages/react-email/src/contexts/emails.tsx +++ b/packages/react-email/src/contexts/emails.tsx @@ -1,10 +1,8 @@ 'use client'; import { createContext, useContext, useState } from 'react'; -import { - getEmailsDirectoryMetadata, - type EmailsDirectory, -} from '../actions/get-emails-directory-metadata'; +import { getEmailsDirectoryMetadataAction } from '../actions/get-emails-directory-metadata-action'; import { useHotreload } from '../hooks/use-hot-reload'; +import type { EmailsDirectory } from '../utils/get-emails-directory-metadata'; const EmailsContext = createContext< | { @@ -37,7 +35,7 @@ export const EmailsProvider = (props: { // the rules of hooks // eslint-disable-next-line react-hooks/rules-of-hooks useHotreload(async () => { - const metadata = await getEmailsDirectoryMetadata( + const metadata = await getEmailsDirectoryMetadataAction( props.initialEmailsDirectoryMetadata.absolutePath, ); if (metadata) { diff --git a/packages/react-email/src/actions/get-emails-directory-metadata.spec.ts b/packages/react-email/src/utils/get-emails-directory-metadata.spec.ts similarity index 98% rename from packages/react-email/src/actions/get-emails-directory-metadata.spec.ts rename to packages/react-email/src/utils/get-emails-directory-metadata.spec.ts index 051d14fc93..13907854f8 100644 --- a/packages/react-email/src/actions/get-emails-directory-metadata.spec.ts +++ b/packages/react-email/src/utils/get-emails-directory-metadata.spec.ts @@ -4,7 +4,7 @@ import { getEmailsDirectoryMetadata } from './get-emails-directory-metadata'; test('getEmailsDirectoryMetadata on demo emails', async () => { const emailsDirectoryPath = path.resolve( __dirname, - '../../../../apps/demo/emails/', + '../../../../apps/demo/emails', ); expect(await getEmailsDirectoryMetadata(emailsDirectoryPath)).toEqual({ absolutePath: emailsDirectoryPath, diff --git a/packages/react-email/src/utils/get-emails-directory-metadata.ts b/packages/react-email/src/utils/get-emails-directory-metadata.ts new file mode 100644 index 0000000000..bfbafa9c2b --- /dev/null +++ b/packages/react-email/src/utils/get-emails-directory-metadata.ts @@ -0,0 +1,119 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import fs from 'node:fs'; +import path from 'node:path'; + +const isFileAnEmail = (fullPath: string): boolean => { + const stat = fs.statSync(fullPath); + + if (stat.isDirectory()) return false; + + const { ext } = path.parse(fullPath); + + if (!['.js', '.tsx', '.jsx'].includes(ext)) return false; + + // This is to avoid a possible race condition where the file doesn't exist anymore + // once we are checking if it is an actual email, this couuld cause issues that + // would be very hard to debug and find out the why of it happening. + if (!fs.existsSync(fullPath)) { + return false; + } + + // check with a heuristic to see if the file has at least + // a default export + const fileContents = fs.readFileSync(fullPath, 'utf8'); + + return /\bexport\s+default\b/gm.test(fileContents); +}; + +export interface EmailsDirectory { + absolutePath: string; + relativePath: string; + directoryName: string; + emailFilenames: string[]; + subDirectories: EmailsDirectory[]; +} + +const mergeDirectoriesWithSubDirectories = ( + emailsDirectoryMetadata: EmailsDirectory, +): EmailsDirectory => { + let currentResultingMergedDirectory: EmailsDirectory = + emailsDirectoryMetadata; + + while ( + currentResultingMergedDirectory.emailFilenames.length === 0 && + currentResultingMergedDirectory.subDirectories.length === 1 + ) { + const onlySubDirectory = currentResultingMergedDirectory.subDirectories[0]!; + currentResultingMergedDirectory = { + ...onlySubDirectory, + directoryName: path.join( + currentResultingMergedDirectory.directoryName, + onlySubDirectory.directoryName, + ), + }; + } + + return currentResultingMergedDirectory; +}; + +export const getEmailsDirectoryMetadata = async ( + absolutePathToEmailsDirectory: string, + keepFileExtensions = false, + isSubDirectory = false, + + baseDirectoryPath = absolutePathToEmailsDirectory, +): Promise => { + if (!fs.existsSync(absolutePathToEmailsDirectory)) return; + + const dirents = await fs.promises.readdir(absolutePathToEmailsDirectory, { + withFileTypes: true, + }); + + const emailFilenames = dirents + .filter((dirent) => + isFileAnEmail(path.join(absolutePathToEmailsDirectory, dirent.name)), + ) + .map((dirent) => + keepFileExtensions + ? dirent.name + : dirent.name.replace(path.extname(dirent.name), ''), + ); + + const subDirectories = await Promise.all( + dirents + .filter( + (dirent) => + dirent.isDirectory() && + !dirent.name.startsWith('_') && + dirent.name !== 'static', + ) + .map((dirent) => { + const direntAbsolutePath = path.join( + absolutePathToEmailsDirectory, + dirent.name, + ); + + return getEmailsDirectoryMetadata( + direntAbsolutePath, + keepFileExtensions, + true, + baseDirectoryPath, + ) as Promise; + }), + ); + + const emailsMetadata = { + absolutePath: absolutePathToEmailsDirectory, + relativePath: path.relative( + baseDirectoryPath, + absolutePathToEmailsDirectory, + ), + directoryName: absolutePathToEmailsDirectory.split(path.sep).pop()!, + emailFilenames, + subDirectories, + } satisfies EmailsDirectory; + + return isSubDirectory + ? mergeDirectoriesWithSubDirectories(emailsMetadata) + : emailsMetadata; +};