diff --git a/.changeset/afraid-snakes-help.md b/.changeset/afraid-snakes-help.md
new file mode 100644
index 0000000000..82dc9cdff9
--- /dev/null
+++ b/.changeset/afraid-snakes-help.md
@@ -0,0 +1,5 @@
+---
+'@sitecore-content-sdk/react': patch
+---
+
+Fix `RichText` recreating nested DOM on parent re-renders by memoizing `dangerouslySetInnerHTML`, preserving event listeners on unchanged HTML.
diff --git a/packages/nextjs/test/setup.js b/packages/nextjs/test/setup.js
index 8fc3f218d2..2c661189be 100644
--- a/packages/nextjs/test/setup.js
+++ b/packages/nextjs/test/setup.js
@@ -1,3 +1,24 @@
+const Module = require('module');
+
+// Ensure a single React instance when tests load @sitecore-content-sdk/react.
+// That package may otherwise resolve its own nested react and break hooks (e.g. useMemo).
+const originalResolveFilename = Module._resolveFilename;
+const forcedReactIds = new Set([
+ 'react',
+ 'react-dom',
+ 'react/jsx-runtime',
+ 'react/jsx-dev-runtime',
+ 'react-dom/client',
+]);
+
+Module._resolveFilename = function (request, parent, isMain, options) {
+ if (forcedReactIds.has(request)) {
+ // Resolve from this package (nextjs) instead of the requiring package's nested copy.
+ return originalResolveFilename.call(this, request, module, isMain, options);
+ }
+ return originalResolveFilename.call(this, request, parent, isMain, options);
+};
+
require('ts-node/register/transpile-only');
require('../src/tests/request.ts');
require('../src/tests/jsdom-setup.ts');
diff --git a/packages/react/src/components/RichText.test.tsx b/packages/react/src/components/RichText.test.tsx
index f671774f33..7d9377c333 100644
--- a/packages/react/src/components/RichText.test.tsx
+++ b/packages/react/src/components/RichText.test.tsx
@@ -55,6 +55,33 @@ describe('', () => {
expect(rendered[0].innerHTML).to.contain(field.value);
});
+ it('should keep the same DOM nodes when re-rendered with unchanged html', () => {
+ const field = {
+ value: '
Hello link
',
+ };
+
+ const Parent = ({ tick }: { tick: number }) => (
+
+
+
+ );
+
+ const { container, rerender } = render();
+ const linkBefore = container.querySelector('#rt-link') as HTMLAnchorElement & {
+ __marker?: boolean;
+ };
+ expect(linkBefore).to.not.equal(null);
+ linkBefore.__marker = true;
+
+ rerender();
+
+ const linkAfter = container.querySelector('#rt-link') as HTMLAnchorElement & {
+ __marker?: boolean;
+ };
+ expect(linkAfter).to.equal(linkBefore);
+ expect(linkAfter.__marker).to.equal(true);
+ });
+
it('should render tag with a tag provided', () => {
const field = {
value: 'value',
diff --git a/packages/react/src/components/RichText.tsx b/packages/react/src/components/RichText.tsx
index c932223c28..c298dba742 100644
--- a/packages/react/src/components/RichText.tsx
+++ b/packages/react/src/components/RichText.tsx
@@ -1,5 +1,5 @@
'use client';
-import React from 'react';
+import React, { useMemo } from 'react';
import { isFieldValueEmpty, type RichTextField } from '@sitecore-content-sdk/content/layout';
import { withFieldMetadata } from '../enhancers/withFieldMetadata';
import { withEmptyFieldEditingComponent } from '../enhancers/withEmptyFieldEditingComponent';
@@ -24,16 +24,24 @@ export interface RichTextProps extends EditableFieldProps {
}
const RichTextComponent: React.FC = ({ field, tag = 'div', ref, ...otherProps }) => {
- if (isFieldValueEmpty(field)) {
+ const html = field?.value;
+
+ // Keep the object reference stable across re-renders when the html is unchanged,
+ // since React DOM compares dangerouslySetInnerHTML by reference and re-sets
+ // innerHTML (recreating all child DOM nodes) whenever it changes.
+ const dangerouslySetInnerHTML = useMemo(
+ () => (html !== undefined && html !== '' ? { __html: html } : undefined),
+ [html]
+ );
+
+ if (isFieldValueEmpty(field) || !dangerouslySetInnerHTML) {
return null;
}
delete otherProps.editable; // prevent editable from being passed to the DOM
const htmlProps = {
- dangerouslySetInnerHTML: {
- __html: field.value,
- },
+ dangerouslySetInnerHTML,
ref,
suppressHydrationWarning: field.metadata ? true : undefined,
...otherProps,