Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/WEB.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,9 @@ On web, HTML is sanitized automatically with [DOMPurify](https://github.com/cure
### Custom mention attributes

To attach custom data to a mention, use the `data-` prefix (e.g. `data-user-id`) to make sure they survive sanitization. Attributes passed to the `setMention` ref method are properly sanitized.

## Client-only rendering (no SSR)

Both `EnrichedText` and `EnrichedTextInput` are **client-only** components. They rely on browser-only APIs (`DOMParser`, `DOMPurify`, `TipTap`) and are **not designed for server-side rendering (SSR)**.

If your application uses SSR (Next.js, Remix, Gatsby, etc.), make sure these components only render on the client.
3 changes: 3 additions & 0 deletions src/web/EnrichedText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useImageErrorFallback } from './useImageErrorFallback';
import { usePressInteractions } from './usePressInteractions';
import { adaptWebToNativeEvent } from './adaptWebToNativeEvent';
import { useStableRef } from './useStableRef';
import { assertBrowserEnvironment } from './assertBrowserEnvironment';

export const EnrichedText = memo(
({
Expand All @@ -35,6 +36,8 @@ export const EnrichedText = memo(
onLinkPress,
onMentionPress,
}: EnrichedTextProps) => {
assertBrowserEnvironment('EnrichedText');

const containerRef = useRef<HTMLDivElement>(null);

useImperativeHandle(ref, () => ({
Expand Down
3 changes: 3 additions & 0 deletions src/web/EnrichedTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import {
checkMentionAttributes,
sanitizeMentionAttributes,
} from './sanitization/htmlSanitizer';
import { assertBrowserEnvironment } from './assertBrowserEnvironment';

function runFocused(
editor: Editor,
Expand Down Expand Up @@ -125,6 +126,8 @@ export const EnrichedTextInput = ({
htmlStyle,
useHtmlNormalizer = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.useHtmlNormalizer,
}: EnrichedTextInputProps) => {
assertBrowserEnvironment('EnrichedTextInput');

const tiptapContent =
defaultValue != null
? prepareHtmlForTiptap(defaultValue, useHtmlNormalizer)
Expand Down
29 changes: 29 additions & 0 deletions src/web/__tests__/assertBrowserEnvironment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { assertBrowserEnvironment } from '../assertBrowserEnvironment';

describe('assertBrowserEnvironment', () => {
// jsdom provides a full DOM, so the browser APIs are present by default.
test('does not throw when the DOM globals are available', () => {
expect(() => assertBrowserEnvironment('EnrichedText')).not.toThrow();
});

// Each of these globals is required - removing any one should trip the assertion.
test.each(['window', 'document', 'DOMParser', 'Node'] as const)(
'throws when %s is missing',
(globalName) => {
// Simulate an SSR environment where each global is undefined.
const original = Object.getOwnPropertyDescriptor(globalThis, globalName);
Object.defineProperty(globalThis, globalName, {
value: undefined,
configurable: true,
});

try {
expect(() => assertBrowserEnvironment('EnrichedText')).toThrow(
/\[react-native-enriched\] EnrichedText is a client-only component/
);
} finally {
Object.defineProperty(globalThis, globalName, original!);
}
Comment thread
hejsztynx marked this conversation as resolved.
Outdated
}
);
});
21 changes: 21 additions & 0 deletions src/web/assertBrowserEnvironment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* `EnrichedText` and `EnrichedTextInput` rely on browser-only APIs (DOMParser,
* DOMPurify, TipTap) and therefore cannot render without a DOM — e.g. during
* server-side rendering (SSR). They are client-only components.
*
* This asserts a DOM is available and throws a clear error otherwise.
*/
export function assertBrowserEnvironment(componentName: string): void {
const hasDOM =
typeof window !== 'undefined' &&
typeof document !== 'undefined' &&
typeof DOMParser !== 'undefined' &&
typeof Node !== 'undefined';

if (!hasDOM) {
throw new Error(
`[react-native-enriched] ${componentName} is a client-only component and cannot be rendered without a DOM. ` +
`If you are running an SSR application, make sure the component is only rendered on the client.`
);
Comment thread
hejsztynx marked this conversation as resolved.
Comment thread
Copilot marked this conversation as resolved.
}
Comment thread
hejsztynx marked this conversation as resolved.
}
Comment thread
Copilot marked this conversation as resolved.
2 changes: 0 additions & 2 deletions src/web/normalization/htmlNormalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,8 +679,6 @@ function walkNode(node: Node, out: { buf: string }): void {
}

export function normalizeHtml(html: string): string {
if (typeof DOMParser === 'undefined') return html;

const parser = new DOMParser();
const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
const body = doc.body;
Comment thread
hejsztynx marked this conversation as resolved.
Expand Down
2 changes: 0 additions & 2 deletions src/web/normalization/prepareHtmlForWeb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ export function prepareHtmlForWeb(
html: string,
useHtmlNormalizer: boolean | undefined
): string {
if (typeof DOMParser === 'undefined') return html;

if (useHtmlNormalizer) {
html = normalizeHtml(html);
}
Expand Down