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,