Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/afraid-snakes-help.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions packages/nextjs/test/setup.js
Original file line number Diff line number Diff line change
@@ -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');
27 changes: 27 additions & 0 deletions packages/react/src/components/RichText.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,33 @@ describe('<RichText />', () => {
expect(rendered[0].innerHTML).to.contain(field.value);
});

it('should keep the same DOM nodes when re-rendered with unchanged html', () => {
const field = {
value: '<p>Hello <a id="rt-link" href="/foo">link</a></p>',
};

const Parent = ({ tick }: { tick: number }) => (
<div data-tick={tick}>
<RichText field={field} />
</div>
);

const { container, rerender } = render(<Parent tick={0} />);
const linkBefore = container.querySelector('#rt-link') as HTMLAnchorElement & {
__marker?: boolean;
};
expect(linkBefore).to.not.equal(null);
linkBefore.__marker = true;

rerender(<Parent tick={1} />);

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',
Expand Down
18 changes: 13 additions & 5 deletions packages/react/src/components/RichText.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -24,16 +24,24 @@ export interface RichTextProps extends EditableFieldProps<RichTextProps> {
}

const RichTextComponent: React.FC<RichTextProps> = ({ 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,
Expand Down
Loading