diff --git a/.changeset/body-inherits-html-lang-dir.md b/.changeset/body-inherits-html-lang-dir.md new file mode 100644 index 0000000000..263310c07d --- /dev/null +++ b/.changeset/body-inherits-html-lang-dir.md @@ -0,0 +1,5 @@ +--- +"react-email": patch +--- + +Make `` inherit `lang` and `dir` from `` instead of always defaulting to `lang="en" dir="ltr"`, so non-English emails no longer end up with contradictory language metadata diff --git a/apps/docs/components/html.mdx b/apps/docs/components/html.mdx index d3bd2ce39c..ce6b362573 100644 --- a/apps/docs/components/html.mdx +++ b/apps/docs/components/html.mdx @@ -49,10 +49,12 @@ const Email = () => { ## Props - Identify the language of text content on the email + Identify the language of text content on the email. Inherited by ``, + which repeats it for email clients that strip the `` tag. - Identify the direction of text content on the email + Identify the direction of text content on the email. Inherited by ``, + which repeats it for email clients that strip the `` tag. diff --git a/packages/react-email/package.json b/packages/react-email/package.json index 5dd6ff93d5..5e9ca18942 100644 --- a/packages/react-email/package.json +++ b/packages/react-email/package.json @@ -80,6 +80,7 @@ "next": "catalog:", "react": "catalog:", "react-dom": "catalog:", + "react-server-dom-webpack": "19.2.4", "shelljs": "0.10.0", "shlex": "3.0.0", "tsx": "catalog:", diff --git a/packages/react-email/src/components/body/body.spec.tsx b/packages/react-email/src/components/body/body.spec.tsx index f5051b6016..392ea9ae80 100644 --- a/packages/react-email/src/components/body/body.spec.tsx +++ b/packages/react-email/src/components/body/body.spec.tsx @@ -1,4 +1,6 @@ import { render } from '@react-email/render'; +import { Html } from '../html/index.js'; +import { Tailwind } from '../tailwind/index.js'; import { Body } from './index.js'; import { marginProperties, paddingProperties } from './margin-properties.js'; @@ -55,6 +57,103 @@ describe(' component', () => { expect(tdStyle).toContain('padding:20px'); }); + describe('lang and dir inheritance from ', () => { + const getAttributes = (html: string, tag: 'html' | 'body' | 'td') => { + const attributes = html.match(new RegExp(`<${tag}([^>]*)>`))?.[1] ?? ''; + return { + lang: attributes.match(/lang="([^"]*)"/)?.[1], + dir: attributes.match(/dir="([^"]*)"/)?.[1], + }; + }; + + it('defaults to lang="en" dir="ltr" without an parent', async () => { + const html = await render(Test); + expect(getAttributes(html, 'body')).toEqual({ lang: 'en', dir: 'ltr' }); + expect(getAttributes(html, 'td')).toEqual({ lang: 'en', dir: 'ltr' }); + }); + + it('inherits lang and dir set on ', async () => { + const html = await render( + + Test + , + ); + expect(getAttributes(html, 'body')).toEqual({ lang: 'ar', dir: 'rtl' }); + expect(getAttributes(html, 'td')).toEqual({ lang: 'ar', dir: 'rtl' }); + }); + + it("inherits 's defaults when it has no explicit lang/dir", async () => { + const html = await render( + + Test + , + ); + expect(getAttributes(html, 'body')).toEqual({ lang: 'en', dir: 'ltr' }); + }); + + it('lets explicit lang and dir on win over ', async () => { + const html = await render( + + + Test + + , + ); + expect(getAttributes(html, 'html')).toEqual({ lang: 'pl', dir: 'ltr' }); + expect(getAttributes(html, 'body')).toEqual({ lang: 'en', dir: 'rtl' }); + }); + + it('inherits lang and dir through custom components', async () => { + const Content = ({ children }: { children: React.ReactNode }) => { + return {children}; + }; + const Layout = ({ children }: { children: React.ReactNode }) => { + return {children}; + }; + + const html = await render( + + Cześć + , + ); + expect(getAttributes(html, 'body')).toEqual({ lang: 'pl', dir: 'ltr' }); + expect(getAttributes(html, 'td')).toEqual({ lang: 'pl', dir: 'ltr' }); + }); + + it('inherits lang and dir with in between', async () => { + const html = await render( + + + Cześć + + , + ); + expect(getAttributes(html, 'body')).toEqual({ lang: 'pl', dir: 'ltr' }); + expect(html).toContain('background-color'); + }); + + it('inherits lang and dir with around ', async () => { + const html = await render( + + + Cześć + + , + ); + expect(getAttributes(html, 'body')).toEqual({ lang: 'pl', dir: 'ltr' }); + expect(html).toContain('background-color'); + }); + + it('does not leak the private context prop into the markup', async () => { + const html = await render( + + Test + , + ); + expect(html.toLowerCase()).not.toContain('reactemailcontexts'); + }); + }); + describe('padding resetting behavior', () => { for (const property of paddingProperties) { it(`resets the ${property} property on body when it comes from props`, async () => { diff --git a/packages/react-email/src/components/body/body.tsx b/packages/react-email/src/components/body/body.tsx index 5a700dd719..1a9c6e6a77 100644 --- a/packages/react-email/src/components/body/body.tsx +++ b/packages/react-email/src/components/body/body.tsx @@ -1,11 +1,28 @@ import * as React from 'react'; import { markAsElement } from '../element-marker.js'; +import { + markAsEmailContextConsumer, + readEmailContext, + stripEmailContexts, +} from '../email-context/index.js'; +import { htmlContext } from '../html/html-context.js'; import { marginProperties, paddingProperties } from './margin-properties.js'; export type BodyProps = Readonly>; export const Body = React.forwardRef( ({ children, style, ...props }, ref) => { + // Email clients like Gmail may strip the html tag, so the language + // metadata is repeated here. It is inherited from to avoid + // conflicting values in the same document. + // See https://github.com/resend/react-email/issues/3652. + const inherited = readEmailContext(props, htmlContext); + const { + dir = inherited.dir ?? 'ltr', + lang = inherited.lang ?? 'en', + ...restProps + } = stripEmailContexts(props); + const bodyStyle: Record = { background: style?.background, backgroundColor: style?.backgroundColor, @@ -20,13 +37,7 @@ export const Body = React.forwardRef( } } return ( - + ( See https://github.com/resend/react-email/issues/662. */} - @@ -60,3 +67,4 @@ export const Body = React.forwardRef( Body.displayName = 'Body'; markAsElement(Body); +markAsEmailContextConsumer(Body); diff --git a/packages/react-email/src/components/email-context/create-email-context.ts b/packages/react-email/src/components/email-context/create-email-context.ts new file mode 100644 index 0000000000..d72a06ebcc --- /dev/null +++ b/packages/react-email/src/components/email-context/create-email-context.ts @@ -0,0 +1,106 @@ +import type * as React from 'react'; +import { + contextsPropName, + type EmailContextMap, + markAsEmailContextConsumer, + markAsEmailContextProvider, +} from './markers.js'; +import { propagateEmailContexts } from './propagate-email-contexts.js'; + +/** + * A context meant to be used by the email components, mimicking React + * context without relying on it. + * + * React context cannot be used for the email components because they may be + * rendered inside React Server Components, where `createContext` and hooks + * are not available, while also being rendered with `react-dom/server` or in + * the browser. + * + * Instead of the value living in the renderer, it is carried through the + * element tree itself: a provider component walks its children at render + * time and clones every registered consumer element with the value injected + * into a private prop (see `propagateEmailContexts` for how component + * boundaries are crossed). Consumers then read it with `readEmailContext`, + * which is a plain function and not a hook. + */ +export interface EmailContext { + id: symbol; + defaultValue: Value; +} + +export const createEmailContext = ( + name: string, + defaultValue: Value, +): EmailContext => ({ + // Registered globally so that duplicated copies of a context's module + // (e.g. the CJS and ESM builds loaded in the same process) interoperate. + id: Symbol.for(`react-email.email-context.${name}`), + defaultValue, +}); + +/** + * Reads the value provided for `context` from a consumer component's props. + * + * The component must have been marked with `markAsEmailContextConsumer` for + * providers to inject values into it. Falls back to the context's default + * value when the component is rendered outside of a provider or when the + * propagation could not reach it (see the caveats on + * `propagateEmailContexts`). + */ +export const readEmailContext = ( + props: object, + context: EmailContext, +): Value => { + const contexts = (props as Record)[contextsPropName] as + | EmailContextMap + | undefined; + if (contexts && context.id in contexts) { + return contexts[context.id] as Value; + } + return context.defaultValue; +}; + +/** + * Removes the private prop used to carry email context values so that it + * doesn't leak into the DOM. Consumers and providers must call this before + * spreading their remaining props into a host element. + */ +export const stripEmailContexts = ( + props: Props, +): Props => { + if (!(contextsPropName in props)) { + return props; + } + const { [contextsPropName]: _contexts, ...rest } = props as Props & { + [contextsPropName]?: EmailContextMap; + }; + return rest as unknown as Props; +}; + +/** + * Provides a value for `context` to all consumer elements found inside + * `children`, mimicking what rendering a React context provider would do. + * + * The component calling this must be marked with + * `markAsEmailContextProvider` and pass its own props as `providerProps`: + * outer providers stop their propagation at nested provider elements and + * inject the values they carry into them, so the nested provider is + * responsible for merging its own value over the inherited ones and + * continuing the propagation, which is what this function does. + */ +export const provideEmailContext = ( + context: EmailContext, + value: Value, + children: React.ReactNode, + providerProps: object, +): React.ReactNode => { + const inherited = (providerProps as Record)[ + contextsPropName + ] as EmailContextMap | undefined; + return propagateEmailContexts(children, { + ...inherited, + [context.id]: value, + }); +}; + +export { markAsEmailContextConsumer, markAsEmailContextProvider }; diff --git a/packages/react-email/src/components/email-context/email-context.spec.tsx b/packages/react-email/src/components/email-context/email-context.spec.tsx new file mode 100644 index 0000000000..3a5d953370 --- /dev/null +++ b/packages/react-email/src/components/email-context/email-context.spec.tsx @@ -0,0 +1,269 @@ +import { render } from '@react-email/render'; +import * as React from 'react'; +import { flushSync } from 'react-dom'; +import { createRoot } from 'react-dom/client'; +import { + createEmailContext, + markAsEmailContextConsumer, + markAsEmailContextProvider, + provideEmailContext, + readEmailContext, +} from './index.js'; + +const valueContext = createEmailContext( + 'email-context.spec', + 'default value', +); + +const Consumer = (props: { children?: React.ReactNode }) => { + const value = readEmailContext(props, valueContext); + return ( + + {props.children} + + ); +}; +markAsEmailContextConsumer(Consumer); + +const Provider = (props: { value: string; children?: React.ReactNode }) => { + return provideEmailContext(valueContext, props.value, props.children, props); +}; +markAsEmailContextProvider(Provider); + +describe('email contexts', () => { + it('provides the default value without a provider', async () => { + const html = await render(); + expect(html).toContain('data-value="default value"'); + }); + + it('provides values to direct children', async () => { + const html = await render( + + + , + ); + expect(html).toContain('data-value="direct"'); + }); + + it('provides values through host elements, fragments and arrays', async () => { + const html = await render( + +
+ <> + {[ +
+ {children}
+ + + + + +
+ +
, + ]} + + + , + ); + expect(html).toContain('data-value="structural"'); + }); + + it('provides values through custom function components', async () => { + const Layout = ({ children }: { children: React.ReactNode }) => { + return
{children}
; + }; + const DeepLayout = () => { + return ( + + + + ); + }; + + const html = await render( + + + , + ); + expect(html).toContain('data-value="through components"'); + }); + + it('provides values through components that use hooks', async () => { + const Layout = ({ children }: { children: React.ReactNode }) => { + const [state] = React.useState('state value'); + const id = React.useId(); + return ( +
+ {children} +
+ ); + }; + + const html = await render( + + + + + , + ); + expect(html).toContain('data-value="with hooks"'); + expect(html).toContain('data-state="state value"'); + }); + + it('provides values through forwardRef and memo components', async () => { + const ForwardRefLayout = React.forwardRef< + HTMLDivElement, + { children: React.ReactNode } + >(({ children }, ref) => { + return
{children}
; + }); + const MemoLayout = React.memo( + ({ children }: { children: React.ReactNode }) => { + return
{children}
; + }, + ); + + const html = await render( + + + + + + + , + ); + expect(html).toContain('data-value="exotic components"'); + }); + + it('lets the closest provider win', async () => { + const html = await render( + + + + + + , + ); + expect(html).toContain('data-value="outer"'); + expect(html).toContain('data-value="inner"'); + }); + + it('keeps values from other contexts when nesting different providers', async () => { + const otherContext = createEmailContext( + 'email-context.spec.other', + 'other default', + ); + const OtherConsumer = (props: object) => { + const value = readEmailContext(props, valueContext); + const otherValue = readEmailContext(props, otherContext); + return ; + }; + markAsEmailContextConsumer(OtherConsumer); + const OtherProvider = (props: { + value: string; + children?: React.ReactNode; + }) => { + return provideEmailContext( + otherContext, + props.value, + props.children, + props, + ); + }; + markAsEmailContextProvider(OtherProvider); + + const html = await render( + + + + + , + ); + expect(html).toContain('data-value="from outer provider"'); + expect(html).toContain('data-other-value="from inner provider"'); + }); + + it('provides values through async components', async () => { + const AsyncLayout = async ({ children }: { children: React.ReactNode }) => { + await new Promise((resolve) => setTimeout(resolve, 5)); + return
{children}
; + }; + + const html = await render( + + + + + , + ); + expect(html).toContain('data-value="through async components"'); + }); + + it('provides values when client-rendered in the browser', () => { + const Layout = ({ children }: { children: React.ReactNode }) => { + const [state] = React.useState('client state'); + return
{children}
; + }; + + const container = document.createElement('div'); + const root = createRoot(container); + flushSync(() => { + root.render( + + + + + , + ); + }); + + expect(container.innerHTML).toContain('data-value="client rendered"'); + expect(container.innerHTML).toContain('data-state="client state"'); + root.unmount(); + }); + + it('does not leak the private prop into the markup', async () => { + const html = await render( + + + , + ); + expect(html.toLowerCase()).not.toContain('reactemailcontexts'); + }); + + it('does not reach consumers inside class components', async () => { + class ClassLayout extends React.Component<{ + children?: React.ReactNode; + }> { + render() { + return ; + } + } + + const html = await render( + + + , + ); + expect(html).toContain('data-value="default value"'); + }); + + it('still reaches consumers passed as children to class components', async () => { + class ClassLayout extends React.Component<{ + children?: React.ReactNode; + }> { + render() { + return
{this.props.children}
; + } + } + + const html = await render( + + + + + , + ); + expect(html).toContain('data-value="class children"'); + }); +}); diff --git a/packages/react-email/src/components/email-context/index.ts b/packages/react-email/src/components/email-context/index.ts new file mode 100644 index 0000000000..55a4a637c9 --- /dev/null +++ b/packages/react-email/src/components/email-context/index.ts @@ -0,0 +1,2 @@ +export * from './create-email-context.js'; +export * from './propagate-email-contexts.js'; diff --git a/packages/react-email/src/components/email-context/markers.ts b/packages/react-email/src/components/email-context/markers.ts new file mode 100644 index 0000000000..1ac69cf455 --- /dev/null +++ b/packages/react-email/src/components/email-context/markers.ts @@ -0,0 +1,50 @@ +/** + * The prop under which email context values are injected into consumer + * and provider components. Consumers and providers must strip this prop + * (see `stripEmailContexts`) before spreading their props into host elements. + */ +export const contextsPropName = '__reactEmailContexts'; + +/** + * A map of context values keyed by each context's unique symbol id. + * + * Symbols are registered globally (`Symbol.for`) so that duplicated copies of + * this module (e.g. the CJS and ESM builds loaded in the same process) remain + * compatible with each other. + */ +export type EmailContextMap = Record; + +export const contextProviderMarker = Symbol.for( + 'react-email.email-context.provider', +); + +export const contextConsumerMarker = Symbol.for( + 'react-email.email-context.consumer', +); + +/** + * Marks a component as an email context provider. During propagation, + * providers receive the inherited context values through their props and + * are expected to continue propagation themselves (see `provideEmailContext`) + * so that their own value gets merged with the inherited ones. + */ +export const markAsEmailContextProvider = (component: unknown) => { + (component as Record)[contextProviderMarker] = true; +}; + +/** + * Marks a component as an email context consumer, making propagation inject + * the current context values into its props so they can be read with + * `readEmailContext`. + */ +export const markAsEmailContextConsumer = (component: unknown) => { + (component as Record)[contextConsumerMarker] = true; +}; + +export const hasMarker = (type: unknown, marker: symbol): boolean => { + return ( + (typeof type === 'function' || + (typeof type === 'object' && type !== null)) && + marker in (type as object) + ); +}; diff --git a/packages/react-email/src/components/email-context/propagate-email-contexts.tsx b/packages/react-email/src/components/email-context/propagate-email-contexts.tsx new file mode 100644 index 0000000000..83ec3fa7fb --- /dev/null +++ b/packages/react-email/src/components/email-context/propagate-email-contexts.tsx @@ -0,0 +1,303 @@ +import * as React from 'react'; +import { elementMarker } from '../element-marker.js'; +import { + contextConsumerMarker, + contextProviderMarker, + contextsPropName, + type EmailContextMap, + hasMarker, +} from './markers.js'; + +const forwardRefTypeSymbol = Symbol.for('react.forward_ref'); +const memoTypeSymbol = Symbol.for('react.memo'); +const injectorMarker = Symbol.for('react-email.email-context.injector'); + +type UnknownProps = Record & { children?: React.ReactNode }; + +const isThenable = (value: unknown): value is PromiseLike => + typeof value === 'object' && + value !== null && + typeof (value as PromiseLike).then === 'function'; + +const unwrapMemo = (type: unknown): unknown => { + let current = type; + while ( + typeof current === 'object' && + current !== null && + (current as { $$typeof?: symbol }).$$typeof === memoTypeSymbol + ) { + current = (current as { type: unknown }).type; + } + return current; +}; + +const isForwardRef = ( + type: unknown, +): type is { + render: (props: UnknownProps, ref: unknown) => React.ReactNode; +} => { + return ( + typeof type === 'object' && + type !== null && + (type as { $$typeof?: symbol }).$$typeof === forwardRefTypeSymbol + ); +}; + +const isClassComponent = (type: unknown): boolean => { + return ( + typeof type === 'function' && + type.prototype !== undefined && + type.prototype !== null && + (type.prototype as { isReactComponent?: unknown }).isReactComponent !== + undefined + ); +}; + +interface EmailContextInjectorProps { + componentType: unknown; + componentProps: UnknownProps; + contexts: EmailContextMap; +} + +/** + * Stands in for a component found during propagation. When React renders it, + * it calls the original component the same way React would and continues + * propagating the contexts through whatever the component returned, so that + * consumers hidden behind component boundaries still receive their values. + * + * Hooks used by the original component still work because this runs inside + * an actual React render, they just get attributed to this component. + * Async components (only valid in RSC, where returning a thenable is + * supported) are handled by chaining the propagation onto the promise. + */ +const EmailContextInjector = ({ + componentType, + componentProps, + contexts, +}: EmailContextInjectorProps): React.ReactNode => { + const unwrapped = unwrapMemo(componentType); + + let output: unknown; + if (isForwardRef(unwrapped)) { + const { ref, ...propsWithoutRef } = componentProps; + output = unwrapped.render(propsWithoutRef, ref); + } else if (typeof unwrapped === 'function' && !isClassComponent(unwrapped)) { + output = (unwrapped as (props: UnknownProps) => React.ReactNode)( + componentProps, + ); + } else { + // We cannot safely call this component ourselves (e.g. a class component + // wrapped in memo), so we let React render it as-is. Contexts will not + // reach consumers inside of it, only through its children prop, which + // was already handled during propagation. + return React.createElement( + componentType as React.ElementType, + componentProps, + ); + } + + if (isThenable(output)) { + return output.then((resolved) => + propagateEmailContexts(resolved as React.ReactNode, contexts), + ) as unknown as React.ReactNode; + } + + return propagateEmailContexts(output as React.ReactNode, contexts); +}; +EmailContextInjector.displayName = 'EmailContextInjector'; +(EmailContextInjector as unknown as Record)[injectorMarker] = + true; + +const cloneWithInjectedContexts = ( + element: React.ReactElement, + contexts: EmailContextMap, + newChildren?: { value: React.ReactNode }, +) => { + const existing = element.props[contextsPropName] as + | EmailContextMap + | undefined; + // Contexts already injected by a previous propagation come from a + // provider that is closer to the element, so they take precedence. + const merged = existing ? { ...contexts, ...existing } : contexts; + + const overrides: UnknownProps = { [contextsPropName]: merged }; + if (newChildren) { + overrides.children = newChildren.value; + } + + return React.cloneElement(element, overrides); +}; + +const propagateThroughChildren = ( + element: React.ReactElement, + contexts: EmailContextMap, +): React.ReactNode => { + const children = element.props?.children; + if (children === undefined || children === null) { + return element; + } + + const newChildren = propagateEmailContexts(children, contexts); + if (newChildren === children) { + return element; + } + + return React.cloneElement(element, { children: newChildren }); +}; + +const wrapInInjector = ( + element: React.ReactElement, + contexts: EmailContextMap, +) => { + return React.createElement(EmailContextInjector, { + key: element.key ?? undefined, + componentType: element.type, + componentProps: element.props, + contexts, + }); +}; + +const propagateThroughElement = ( + element: React.ReactElement, + contexts: EmailContextMap, +): React.ReactNode => { + const type = element.type as unknown; + const markerTarget = unwrapMemo(type); + + if (hasMarker(markerTarget, injectorMarker)) { + const existing = (element.props as unknown as EmailContextInjectorProps) + .contexts; + return React.cloneElement(element, { + contexts: { ...contexts, ...existing }, + } as Partial); + } + + if (hasMarker(markerTarget, contextProviderMarker)) { + // The provider merges its own value over the inherited ones and + // continues the propagation through its subtree by itself, so we hand + // the contexts over to it and stop descending. + return cloneWithInjectedContexts(element, contexts); + } + + if (hasMarker(markerTarget, contextConsumerMarker)) { + const children = element.props.children; + const newChildren = + children === undefined || children === null + ? undefined + : { value: propagateEmailContexts(children, contexts) }; + return cloneWithInjectedContexts(element, contexts, newChildren); + } + + if ( + hasMarker(markerTarget, elementMarker) || + typeof type === 'string' || + typeof type === 'symbol' + ) { + // Host elements (e.g. `td`), symbol-typed elements (e.g. Fragment, + // Suspense) and react-email's own primitives just place their children + // where they are, so we only need to keep descending through them. + return propagateThroughChildren(element, contexts); + } + + if (typeof type === 'function') { + if (isClassComponent(type)) { + // Class components cannot be called inline, so contexts only reach + // whatever the user placed inside of their children prop. + return propagateThroughChildren(element, contexts); + } + + return wrapInInjector(element, contexts); + } + + if (typeof type === 'object' && type !== null) { + const typeOfType = (type as { $$typeof?: symbol }).$$typeof; + if (typeOfType === forwardRefTypeSymbol || typeOfType === memoTypeSymbol) { + return wrapInInjector(element, contexts); + } + + // Other exotic types, such as React context providers/consumers, lazy + // components and RSC client references. The most we can do for these is + // keep propagating through the children the user has passed to them. + return propagateThroughChildren(element, contexts); + } + + return element; +}; + +/** + * Walks a React node, injecting the given context values into every element + * marked as an email context consumer (see `markAsEmailContextConsumer`). + * + * Since this cannot rely on React context—email templates may be rendered + * inside RSC where it is not available—values are carried by cloning + * elements with a private prop. Components found along the way are deferred + * to render time through `EmailContextInjector`, which continues the + * propagation through their output. + * + * Caveats, by design: + * - Only the `children` prop is traversed, so consumers inside other + * element-typed props (e.g. Suspense's `fallback`) don't receive values. + * - Consumers rendered inside class components, `React.lazy` components, + * render-prop functions or RSC client references fall back to their + * default values. + */ +export function propagateEmailContexts( + node: React.ReactNode, + contexts: EmailContextMap, +): React.ReactNode { + if (node === null || node === undefined) { + return node; + } + + const typeOfNode = typeof node; + if ( + typeOfNode === 'string' || + typeOfNode === 'number' || + typeOfNode === 'boolean' || + typeOfNode === 'bigint' + ) { + return node; + } + + if (isThenable(node)) { + return node.then((resolved) => + propagateEmailContexts(resolved as React.ReactNode, contexts), + ) as unknown as React.ReactNode; + } + + if (Array.isArray(node)) { + let changed = false as boolean; + const mapped = node.map((child) => { + const result = propagateEmailContexts(child, contexts); + if (result !== child) { + changed = true; + } + return result; + }); + if (!changed) { + return node; + } + // The new array counts as dynamic children, so elements that relied on + // being static JSX children need explicit keys, which are assigned by + // position the same way React.Children.map does. + return mapped.map((child, index) => + React.isValidElement(child) && child.key === null + ? React.cloneElement(child, { key: `.${index}` }) + : child, + ); + } + + if (React.isValidElement(node)) { + return propagateThroughElement(node, contexts); + } + + if ( + typeof (node as Iterable)[Symbol.iterator] === 'function' + ) { + return Array.from(node as Iterable).map((child) => + propagateEmailContexts(child, contexts), + ); + } + + return node; +} diff --git a/packages/react-email/src/components/email-context/rsc/render-with-flight.tsx b/packages/react-email/src/components/email-context/rsc/render-with-flight.tsx new file mode 100644 index 0000000000..3f0b8e6f55 --- /dev/null +++ b/packages/react-email/src/components/email-context/rsc/render-with-flight.tsx @@ -0,0 +1,60 @@ +/** + * Renders email components with React's Flight renderer (the one used for + * React Server Components) to prove that email contexts propagate there. + * + * This is run by rsc-compatibility.spec.ts in a subprocess with + * `--conditions=react-server`, the environment where `React.createContext` + * and hooks don't exist, which is the reason email contexts exist at all. + */ +import assert from 'node:assert'; +import * as React from 'react'; +import { renderToReadableStream } from 'react-server-dom-webpack/server.edge'; +import { Body } from '../../body/index.js'; +import { Html } from '../../html/index.js'; + +assert.strictEqual( + (React as { createContext?: unknown }).createContext, + undefined, + 'Expected React.createContext to not exist, this script must run with --conditions=react-server', +); + +const AsyncLayout = async ({ children }: { children: React.ReactNode }) => { + await new Promise((resolve) => setTimeout(resolve, 5)); + return <>{children}; +}; + +const stream = renderToReadableStream( + + + Cześć + + , + {}, +); + +const flightPayload = await new Response(stream).text(); + +// Flight encodes host elements as ["$","body",key,{...props}], so the +// assertions below check the props that ended up on each host element. +assert.match( + flightPayload, + /"html",[^,]*,\{[^{]*"lang":"pl"/, + `Expected the html element to have lang="pl" in the flight payload:\n${flightPayload}`, +); +assert.match( + flightPayload, + /"body",[^,]*,\{[^{]*"lang":"pl"/, + `Expected the body element to inherit lang="pl" in the flight payload:\n${flightPayload}`, +); +assert.match( + flightPayload, + /"td",[^,]*,\{[^{]*"lang":"pl"/, + `Expected the td element to inherit lang="pl" in the flight payload:\n${flightPayload}`, +); +assert.doesNotMatch( + flightPayload, + /reactemailcontexts/i, + `Expected the private context prop to not leak into the flight payload:\n${flightPayload}`, +); + +console.log('flight rendering worked correctly'); diff --git a/packages/react-email/src/components/email-context/rsc/rsc-compatibility.spec.ts b/packages/react-email/src/components/email-context/rsc/rsc-compatibility.spec.ts new file mode 100644 index 0000000000..009eb5ee5d --- /dev/null +++ b/packages/react-email/src/components/email-context/rsc/rsc-compatibility.spec.ts @@ -0,0 +1,33 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const packageRoot = path.resolve(__dirname, '../../../..'); + +test('email contexts propagate when rendering with the RSC flight renderer', { + timeout: 30_000, +}, async () => { + const scriptLocation = path.join( + 'src', + 'components', + 'email-context', + 'rsc', + 'render-with-flight.tsx', + ); + const { stdout } = await promisify(execFile)( + process.execPath, + ['--conditions=react-server', '--import=tsx/esm', scriptLocation], + { + cwd: packageRoot, + env: { + ...process.env, + // In development, flight payloads include debug metadata that + // repeats component props, which would trip up the script's + // assertion that the private context prop never gets rendered. + NODE_ENV: 'production', + }, + }, + ); + + expect(stdout).toContain('flight rendering worked correctly'); +}); diff --git a/packages/react-email/src/components/html/html-context.ts b/packages/react-email/src/components/html/html-context.ts new file mode 100644 index 0000000000..1769b3b7ea --- /dev/null +++ b/packages/react-email/src/components/html/html-context.ts @@ -0,0 +1,12 @@ +import { createEmailContext } from '../email-context/index.js'; + +export interface HtmlContextValue { + lang?: string; + dir?: string; +} + +/** + * Carries the language metadata set on `` down to components that + * repeat it for email clients that strip the `` tag, such as ``. + */ +export const htmlContext = createEmailContext('html', {}); diff --git a/packages/react-email/src/components/html/html.tsx b/packages/react-email/src/components/html/html.tsx index 85d4500e6f..c7c7b85fe3 100644 --- a/packages/react-email/src/components/html/html.tsx +++ b/packages/react-email/src/components/html/html.tsx @@ -1,13 +1,20 @@ import * as React from 'react'; +import { + markAsEmailContextProvider, + provideEmailContext, + stripEmailContexts, +} from '../email-context/index.js'; +import { htmlContext } from './html-context.js'; export type HtmlProps = Readonly>; export const Html = React.forwardRef( ({ children, lang = 'en', dir = 'ltr', ...props }, ref) => ( - - {children} + + {provideEmailContext(htmlContext, { lang, dir }, children, props)} ), ); Html.displayName = 'Html'; +markAsEmailContextProvider(Html); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cae5bcd28d..2ae47f1949 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -760,6 +760,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + react-server-dom-webpack: + specifier: 19.2.4 + version: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.106.2(esbuild@0.28.0)) shelljs: specifier: 0.10.0 version: 0.10.0 @@ -4450,6 +4453,10 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-loose@8.5.2: + resolution: {integrity: sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==} + engines: {node: '>=0.4.0'} + acorn@8.11.2: resolution: {integrity: sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==} engines: {node: '>=0.4.0'} @@ -7643,6 +7650,15 @@ packages: '@types/react': optional: true + react-server-dom-webpack@19.2.4: + resolution: {integrity: sha512-zEhkWv6RhXDctC2N7yEUHg3751nvFg81ydHj8LTTZuukF/IF1gcOKqqAL6Ds+kS5HtDVACYPik0IvzkgYXPhlQ==} + engines: {node: '>=0.10.0'} + deprecated: High Security Vulnerability in React Server Components + peerDependencies: + react: 19.2.4 + react-dom: 19.2.4 + webpack: ^5.59.0 + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -12890,6 +12906,10 @@ snapshots: dependencies: acorn: 8.16.0 + acorn-loose@8.5.2: + dependencies: + acorn: 8.16.0 + acorn@8.11.2: {} acorn@8.16.0: {} @@ -16663,6 +16683,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.106.2(esbuild@0.28.0)): + dependencies: + acorn-loose: 8.5.2 + neo-async: 2.6.2 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + webpack: 5.106.2(esbuild@0.28.0) + webpack-sources: 3.3.4 + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): dependencies: get-nonce: 1.0.1 @@ -17701,6 +17730,16 @@ snapshots: term-size@2.2.1: {} + terser-webpack-plugin@5.3.17(esbuild@0.28.0)(webpack@5.106.2(esbuild@0.28.0)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.37.0 + webpack: 5.106.2(esbuild@0.28.0) + optionalDependencies: + esbuild: 0.28.0 + terser-webpack-plugin@5.3.17(esbuild@0.28.1)(webpack@5.106.2(esbuild@0.28.1)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -18272,6 +18311,37 @@ snapshots: - esbuild - uglify-js + webpack@5.106.2(esbuild@0.28.0): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) + browserslist: 4.28.1 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.20.0 + es-module-lexer: 2.0.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + loader-runner: 4.3.1 + mime-db: 1.54.0 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.0 + terser-webpack-plugin: 5.3.17(esbuild@0.28.0)(webpack@5.106.2(esbuild@0.28.0)) + watchpack: 2.5.1 + webpack-sources: 3.3.4 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + webpack@5.106.2(esbuild@0.28.1): dependencies: '@types/eslint-scope': 3.7.7