From 4adf24727b7e1186b8474bead75d89f4e0822ccd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Jul 2026 15:19:48 +0000 Subject: [PATCH] fix(react-email): inherit lang and dir on from through email contexts Email contexts mimic React context by carrying values through the element tree itself, since the components may be rendered inside RSC where createContext and hooks are not available. A provider walks its children at render time, injecting values into marked consumer elements through a private prop and crossing component boundaries lazily with an injector component that calls components in place and keeps propagating through their output. now provides its lang and dir this way and consumes them, so non-English emails no longer get a hardcoded lang="en" on the body tag. Fixes #3652 Co-authored-by: Gabriel Miranda --- .changeset/body-inherits-html-lang-dir.md | 5 + apps/docs/components/html.mdx | 6 +- packages/react-email/package.json | 1 + .../src/components/body/body.spec.tsx | 99 ++++++ .../react-email/src/components/body/body.tsx | 32 +- .../email-context/create-email-context.ts | 106 ++++++ .../email-context/email-context.spec.tsx | 269 ++++++++++++++++ .../src/components/email-context/index.ts | 2 + .../src/components/email-context/markers.ts | 50 +++ .../propagate-email-contexts.tsx | 303 ++++++++++++++++++ .../email-context/rsc/render-with-flight.tsx | 60 ++++ .../rsc/rsc-compatibility.spec.ts | 33 ++ .../src/components/html/html-context.ts | 12 + .../react-email/src/components/html/html.tsx | 11 +- pnpm-lock.yaml | 70 ++++ 15 files changed, 1043 insertions(+), 16 deletions(-) create mode 100644 .changeset/body-inherits-html-lang-dir.md create mode 100644 packages/react-email/src/components/email-context/create-email-context.ts create mode 100644 packages/react-email/src/components/email-context/email-context.spec.tsx create mode 100644 packages/react-email/src/components/email-context/index.ts create mode 100644 packages/react-email/src/components/email-context/markers.ts create mode 100644 packages/react-email/src/components/email-context/propagate-email-contexts.tsx create mode 100644 packages/react-email/src/components/email-context/rsc/render-with-flight.tsx create mode 100644 packages/react-email/src/components/email-context/rsc/rsc-compatibility.spec.ts create mode 100644 packages/react-email/src/components/html/html-context.ts 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