Skip to content

Commit 4adf247

Browse files
fix(react-email): inherit lang and dir on <Body> from <Html> 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. <Html> now provides its lang and dir this way and <Body> consumes them, so non-English emails no longer get a hardcoded lang="en" on the body tag. Fixes #3652 Co-authored-by: Gabriel Miranda <gabrielmfern@outlook.com>
1 parent 3966136 commit 4adf247

15 files changed

Lines changed: 1043 additions & 16 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-email": patch
3+
---
4+
5+
Make `<Body>` inherit `lang` and `dir` from `<Html>` instead of always defaulting to `lang="en" dir="ltr"`, so non-English emails no longer end up with contradictory language metadata

apps/docs/components/html.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,12 @@ const Email = () => {
4949
## Props
5050

5151
<ResponseField name="lang" type="string" default="en">
52-
Identify the language of text content on the email
52+
Identify the language of text content on the email. Inherited by `<Body />`,
53+
which repeats it for email clients that strip the `<html>` tag.
5354
</ResponseField>
5455
<ResponseField name="dir" type="string" default="ltr">
55-
Identify the direction of text content on the email
56+
Identify the direction of text content on the email. Inherited by `<Body />`,
57+
which repeats it for email clients that strip the `<html>` tag.
5658
</ResponseField>
5759

5860
<Support/>

packages/react-email/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
"next": "catalog:",
8181
"react": "catalog:",
8282
"react-dom": "catalog:",
83+
"react-server-dom-webpack": "19.2.4",
8384
"shelljs": "0.10.0",
8485
"shlex": "3.0.0",
8586
"tsx": "catalog:",

packages/react-email/src/components/body/body.spec.tsx

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { render } from '@react-email/render';
2+
import { Html } from '../html/index.js';
3+
import { Tailwind } from '../tailwind/index.js';
24
import { Body } from './index.js';
35
import { marginProperties, paddingProperties } from './margin-properties.js';
46

@@ -55,6 +57,103 @@ describe('<Body> component', () => {
5557
expect(tdStyle).toContain('padding:20px');
5658
});
5759

60+
describe('lang and dir inheritance from <Html>', () => {
61+
const getAttributes = (html: string, tag: 'html' | 'body' | 'td') => {
62+
const attributes = html.match(new RegExp(`<${tag}([^>]*)>`))?.[1] ?? '';
63+
return {
64+
lang: attributes.match(/lang="([^"]*)"/)?.[1],
65+
dir: attributes.match(/dir="([^"]*)"/)?.[1],
66+
};
67+
};
68+
69+
it('defaults to lang="en" dir="ltr" without an <Html> parent', async () => {
70+
const html = await render(<Body>Test</Body>);
71+
expect(getAttributes(html, 'body')).toEqual({ lang: 'en', dir: 'ltr' });
72+
expect(getAttributes(html, 'td')).toEqual({ lang: 'en', dir: 'ltr' });
73+
});
74+
75+
it('inherits lang and dir set on <Html>', async () => {
76+
const html = await render(
77+
<Html lang="ar" dir="rtl">
78+
<Body>Test</Body>
79+
</Html>,
80+
);
81+
expect(getAttributes(html, 'body')).toEqual({ lang: 'ar', dir: 'rtl' });
82+
expect(getAttributes(html, 'td')).toEqual({ lang: 'ar', dir: 'rtl' });
83+
});
84+
85+
it("inherits <Html>'s defaults when it has no explicit lang/dir", async () => {
86+
const html = await render(
87+
<Html>
88+
<Body>Test</Body>
89+
</Html>,
90+
);
91+
expect(getAttributes(html, 'body')).toEqual({ lang: 'en', dir: 'ltr' });
92+
});
93+
94+
it('lets explicit lang and dir on <Body> win over <Html>', async () => {
95+
const html = await render(
96+
<Html lang="pl">
97+
<Body lang="en" dir="rtl">
98+
Test
99+
</Body>
100+
</Html>,
101+
);
102+
expect(getAttributes(html, 'html')).toEqual({ lang: 'pl', dir: 'ltr' });
103+
expect(getAttributes(html, 'body')).toEqual({ lang: 'en', dir: 'rtl' });
104+
});
105+
106+
it('inherits lang and dir through custom components', async () => {
107+
const Content = ({ children }: { children: React.ReactNode }) => {
108+
return <Body>{children}</Body>;
109+
};
110+
const Layout = ({ children }: { children: React.ReactNode }) => {
111+
return <Content>{children}</Content>;
112+
};
113+
114+
const html = await render(
115+
<Html lang="pl">
116+
<Layout>Cześć</Layout>
117+
</Html>,
118+
);
119+
expect(getAttributes(html, 'body')).toEqual({ lang: 'pl', dir: 'ltr' });
120+
expect(getAttributes(html, 'td')).toEqual({ lang: 'pl', dir: 'ltr' });
121+
});
122+
123+
it('inherits lang and dir with <Tailwind> in between', async () => {
124+
const html = await render(
125+
<Html lang="pl">
126+
<Tailwind>
127+
<Body className="bg-red-500">Cześć</Body>
128+
</Tailwind>
129+
</Html>,
130+
);
131+
expect(getAttributes(html, 'body')).toEqual({ lang: 'pl', dir: 'ltr' });
132+
expect(html).toContain('background-color');
133+
});
134+
135+
it('inherits lang and dir with <Tailwind> around <Html>', async () => {
136+
const html = await render(
137+
<Tailwind>
138+
<Html lang="pl">
139+
<Body className="bg-red-500">Cześć</Body>
140+
</Html>
141+
</Tailwind>,
142+
);
143+
expect(getAttributes(html, 'body')).toEqual({ lang: 'pl', dir: 'ltr' });
144+
expect(html).toContain('background-color');
145+
});
146+
147+
it('does not leak the private context prop into the markup', async () => {
148+
const html = await render(
149+
<Html lang="pl">
150+
<Body>Test</Body>
151+
</Html>,
152+
);
153+
expect(html.toLowerCase()).not.toContain('reactemailcontexts');
154+
});
155+
});
156+
58157
describe('padding resetting behavior', () => {
59158
for (const property of paddingProperties) {
60159
it(`resets the ${property} property on body when it comes from props`, async () => {

packages/react-email/src/components/body/body.tsx

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,28 @@
11
import * as React from 'react';
22
import { markAsElement } from '../element-marker.js';
3+
import {
4+
markAsEmailContextConsumer,
5+
readEmailContext,
6+
stripEmailContexts,
7+
} from '../email-context/index.js';
8+
import { htmlContext } from '../html/html-context.js';
39
import { marginProperties, paddingProperties } from './margin-properties.js';
410

511
export type BodyProps = Readonly<React.HtmlHTMLAttributes<HTMLBodyElement>>;
612

713
export const Body = React.forwardRef<HTMLBodyElement, BodyProps>(
814
({ children, style, ...props }, ref) => {
15+
// Email clients like Gmail may strip the html tag, so the language
16+
// metadata is repeated here. It is inherited from <Html> to avoid
17+
// conflicting values in the same document.
18+
// See https://github.com/resend/react-email/issues/3652.
19+
const inherited = readEmailContext(props, htmlContext);
20+
const {
21+
dir = inherited.dir ?? 'ltr',
22+
lang = inherited.lang ?? 'en',
23+
...restProps
24+
} = stripEmailContexts(props);
25+
926
const bodyStyle: Record<string, string | number | undefined> = {
1027
background: style?.background,
1128
backgroundColor: style?.backgroundColor,
@@ -20,13 +37,7 @@ export const Body = React.forwardRef<HTMLBodyElement, BodyProps>(
2037
}
2138
}
2239
return (
23-
<body
24-
{...props}
25-
dir={props.dir ?? 'ltr'}
26-
lang={props.lang ?? 'en'}
27-
style={bodyStyle}
28-
ref={ref}
29-
>
40+
<body {...restProps} dir={dir} lang={lang} style={bodyStyle} ref={ref}>
3041
<table
3142
border={0}
3243
width="100%"
@@ -43,11 +54,7 @@ export const Body = React.forwardRef<HTMLBodyElement, BodyProps>(
4354
4455
See https://github.com/resend/react-email/issues/662.
4556
*/}
46-
<td
47-
dir={props.dir ?? 'ltr'}
48-
lang={props.lang ?? 'en'}
49-
style={style}
50-
>
57+
<td dir={dir} lang={lang} style={style}>
5158
{children}
5259
</td>
5360
</tr>
@@ -60,3 +67,4 @@ export const Body = React.forwardRef<HTMLBodyElement, BodyProps>(
6067

6168
Body.displayName = 'Body';
6269
markAsElement(Body);
70+
markAsEmailContextConsumer(Body);
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type * as React from 'react';
2+
import {
3+
contextsPropName,
4+
type EmailContextMap,
5+
markAsEmailContextConsumer,
6+
markAsEmailContextProvider,
7+
} from './markers.js';
8+
import { propagateEmailContexts } from './propagate-email-contexts.js';
9+
10+
/**
11+
* A context meant to be used by the email components, mimicking React
12+
* context without relying on it.
13+
*
14+
* React context cannot be used for the email components because they may be
15+
* rendered inside React Server Components, where `createContext` and hooks
16+
* are not available, while also being rendered with `react-dom/server` or in
17+
* the browser.
18+
*
19+
* Instead of the value living in the renderer, it is carried through the
20+
* element tree itself: a provider component walks its children at render
21+
* time and clones every registered consumer element with the value injected
22+
* into a private prop (see `propagateEmailContexts` for how component
23+
* boundaries are crossed). Consumers then read it with `readEmailContext`,
24+
* which is a plain function and not a hook.
25+
*/
26+
export interface EmailContext<Value> {
27+
id: symbol;
28+
defaultValue: Value;
29+
}
30+
31+
export const createEmailContext = <Value>(
32+
name: string,
33+
defaultValue: Value,
34+
): EmailContext<Value> => ({
35+
// Registered globally so that duplicated copies of a context's module
36+
// (e.g. the CJS and ESM builds loaded in the same process) interoperate.
37+
id: Symbol.for(`react-email.email-context.${name}`),
38+
defaultValue,
39+
});
40+
41+
/**
42+
* Reads the value provided for `context` from a consumer component's props.
43+
*
44+
* The component must have been marked with `markAsEmailContextConsumer` for
45+
* providers to inject values into it. Falls back to the context's default
46+
* value when the component is rendered outside of a provider or when the
47+
* propagation could not reach it (see the caveats on
48+
* `propagateEmailContexts`).
49+
*/
50+
export const readEmailContext = <Value>(
51+
props: object,
52+
context: EmailContext<Value>,
53+
): Value => {
54+
const contexts = (props as Record<string, unknown>)[contextsPropName] as
55+
| EmailContextMap
56+
| undefined;
57+
if (contexts && context.id in contexts) {
58+
return contexts[context.id] as Value;
59+
}
60+
return context.defaultValue;
61+
};
62+
63+
/**
64+
* Removes the private prop used to carry email context values so that it
65+
* doesn't leak into the DOM. Consumers and providers must call this before
66+
* spreading their remaining props into a host element.
67+
*/
68+
export const stripEmailContexts = <Props extends object>(
69+
props: Props,
70+
): Props => {
71+
if (!(contextsPropName in props)) {
72+
return props;
73+
}
74+
const { [contextsPropName]: _contexts, ...rest } = props as Props & {
75+
[contextsPropName]?: EmailContextMap;
76+
};
77+
return rest as unknown as Props;
78+
};
79+
80+
/**
81+
* Provides a value for `context` to all consumer elements found inside
82+
* `children`, mimicking what rendering a React context provider would do.
83+
*
84+
* The component calling this must be marked with
85+
* `markAsEmailContextProvider` and pass its own props as `providerProps`:
86+
* outer providers stop their propagation at nested provider elements and
87+
* inject the values they carry into them, so the nested provider is
88+
* responsible for merging its own value over the inherited ones and
89+
* continuing the propagation, which is what this function does.
90+
*/
91+
export const provideEmailContext = <Value>(
92+
context: EmailContext<Value>,
93+
value: Value,
94+
children: React.ReactNode,
95+
providerProps: object,
96+
): React.ReactNode => {
97+
const inherited = (providerProps as Record<string, unknown>)[
98+
contextsPropName
99+
] as EmailContextMap | undefined;
100+
return propagateEmailContexts(children, {
101+
...inherited,
102+
[context.id]: value,
103+
});
104+
};
105+
106+
export { markAsEmailContextConsumer, markAsEmailContextProvider };

0 commit comments

Comments
 (0)