diff --git a/docs/WEB.md b/docs/WEB.md index b4816f54..ec0c0f9a 100644 --- a/docs/WEB.md +++ b/docs/WEB.md @@ -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. diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index 63141b0d..5ee869a9 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -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( ({ @@ -35,6 +36,8 @@ export const EnrichedText = memo( onLinkPress, onMentionPress, }: EnrichedTextProps) => { + assertBrowserEnvironment('EnrichedText'); + const containerRef = useRef(null); useImperativeHandle(ref, () => ({ diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 0c5319aa..9d6895e7 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -84,6 +84,7 @@ import { checkMentionAttributes, sanitizeMentionAttributes, } from './sanitization/htmlSanitizer'; +import { assertBrowserEnvironment } from './assertBrowserEnvironment'; function runFocused( editor: Editor, @@ -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) diff --git a/src/web/__tests__/assertBrowserEnvironment.dom.test.ts b/src/web/__tests__/assertBrowserEnvironment.dom.test.ts new file mode 100644 index 00000000..5bb67f5d --- /dev/null +++ b/src/web/__tests__/assertBrowserEnvironment.dom.test.ts @@ -0,0 +1,8 @@ +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(); + }); +}); diff --git a/src/web/__tests__/assertBrowserEnvironment.ssr.test.ts b/src/web/__tests__/assertBrowserEnvironment.ssr.test.ts new file mode 100644 index 00000000..6035147b --- /dev/null +++ b/src/web/__tests__/assertBrowserEnvironment.ssr.test.ts @@ -0,0 +1,13 @@ +/** + * @jest-environment node + */ +// Because of the docblock above, jsdom test environment does not exist here +import { assertBrowserEnvironment } from '../assertBrowserEnvironment'; + +describe('assertBrowserEnvironment', () => { + test('throws when DOM is missing', () => { + expect(() => assertBrowserEnvironment('EnrichedText')).toThrow( + /client-only/ + ); + }); +}); diff --git a/src/web/assertBrowserEnvironment.ts b/src/web/assertBrowserEnvironment.ts new file mode 100644 index 00000000..3fa03721 --- /dev/null +++ b/src/web/assertBrowserEnvironment.ts @@ -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-html] ${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.` + ); + } +} diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index 7b949215..22ecd323 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -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(`${html}`, 'text/html'); const body = doc.body; diff --git a/src/web/normalization/prepareHtmlForWeb.ts b/src/web/normalization/prepareHtmlForWeb.ts index 99c31542..8da6d63e 100644 --- a/src/web/normalization/prepareHtmlForWeb.ts +++ b/src/web/normalization/prepareHtmlForWeb.ts @@ -4,8 +4,6 @@ export function prepareHtmlForWeb( html: string, useHtmlNormalizer: boolean | undefined ): string { - if (typeof DOMParser === 'undefined') return html; - if (useHtmlNormalizer) { html = normalizeHtml(html); }