From d58b20ef856320b23dbd09c16db11956a356a156 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 17 Jul 2026 00:11:24 +0200 Subject: [PATCH 1/3] feat(web): browser environment assertion --- docs/WEB.md | 6 ++++++ src/web/EnrichedText.tsx | 3 +++ src/web/EnrichedTextInput.tsx | 3 +++ src/web/assertBrowserEnvironment.ts | 15 +++++++++++++++ src/web/normalization/htmlNormalizer.ts | 2 -- src/web/normalization/prepareHtmlForWeb.ts | 2 -- 6 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 src/web/assertBrowserEnvironment.ts diff --git a/docs/WEB.md b/docs/WEB.md index b4816f543..ec0c0f9a8 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 63141b0d7..5ee869a99 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 0c5319aa5..9d6895e7f 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/assertBrowserEnvironment.ts b/src/web/assertBrowserEnvironment.ts new file mode 100644 index 000000000..0ba3a6548 --- /dev/null +++ b/src/web/assertBrowserEnvironment.ts @@ -0,0 +1,15 @@ +/** + * `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 { + if (typeof window === 'undefined' || typeof document === 'undefined') { + 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.` + ); + } +} diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index 7b9492152..22ecd3236 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 99c315429..8da6d63e8 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); } From b307032fde529c2871541947052cea3b4d143bba Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 17 Jul 2026 11:16:56 +0200 Subject: [PATCH 2/3] test: browser environment assertion unit tests --- .../assertBrowserEnvironment.test.ts | 29 +++++++++++++++++++ src/web/assertBrowserEnvironment.ts | 8 ++++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 src/web/__tests__/assertBrowserEnvironment.test.ts diff --git a/src/web/__tests__/assertBrowserEnvironment.test.ts b/src/web/__tests__/assertBrowserEnvironment.test.ts new file mode 100644 index 000000000..0f4e608b9 --- /dev/null +++ b/src/web/__tests__/assertBrowserEnvironment.test.ts @@ -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!); + } + } + ); +}); diff --git a/src/web/assertBrowserEnvironment.ts b/src/web/assertBrowserEnvironment.ts index 0ba3a6548..bda7447ec 100644 --- a/src/web/assertBrowserEnvironment.ts +++ b/src/web/assertBrowserEnvironment.ts @@ -6,7 +6,13 @@ * This asserts a DOM is available and throws a clear error otherwise. */ export function assertBrowserEnvironment(componentName: string): void { - if (typeof window === 'undefined' || typeof document === 'undefined') { + 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.` From 8c2efa49f8c70b5dab16b0c74d9419c6313d3eb2 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 17 Jul 2026 11:38:00 +0200 Subject: [PATCH 3/3] test: simplify non dom tests --- .../assertBrowserEnvironment.dom.test.ts | 8 +++++ .../assertBrowserEnvironment.ssr.test.ts | 13 +++++++++ .../assertBrowserEnvironment.test.ts | 29 ------------------- src/web/assertBrowserEnvironment.ts | 2 +- 4 files changed, 22 insertions(+), 30 deletions(-) create mode 100644 src/web/__tests__/assertBrowserEnvironment.dom.test.ts create mode 100644 src/web/__tests__/assertBrowserEnvironment.ssr.test.ts delete mode 100644 src/web/__tests__/assertBrowserEnvironment.test.ts diff --git a/src/web/__tests__/assertBrowserEnvironment.dom.test.ts b/src/web/__tests__/assertBrowserEnvironment.dom.test.ts new file mode 100644 index 000000000..5bb67f5d1 --- /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 000000000..6035147be --- /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/__tests__/assertBrowserEnvironment.test.ts b/src/web/__tests__/assertBrowserEnvironment.test.ts deleted file mode 100644 index 0f4e608b9..000000000 --- a/src/web/__tests__/assertBrowserEnvironment.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -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!); - } - } - ); -}); diff --git a/src/web/assertBrowserEnvironment.ts b/src/web/assertBrowserEnvironment.ts index bda7447ec..3fa03721b 100644 --- a/src/web/assertBrowserEnvironment.ts +++ b/src/web/assertBrowserEnvironment.ts @@ -14,7 +14,7 @@ export function assertBrowserEnvironment(componentName: string): void { if (!hasDOM) { throw new Error( - `[react-native-enriched] ${componentName} is a client-only component and cannot be rendered without a DOM. ` + + `[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.` ); }