Skip to content

[sitecore-jss-react] RichText: stabilize dangerouslySetInnerHTML reference across re-renders#2204

Open
pzi wants to merge 1 commit into
Sitecore:devfrom
pzi:fix/react-richtext-stable-innerhtml
Open

[sitecore-jss-react] RichText: stabilize dangerouslySetInnerHTML reference across re-renders#2204
pzi wants to merge 1 commit into
Sitecore:devfrom
pzi:fix/react-richtext-stable-innerhtml

Conversation

@pzi

@pzi pzi commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Description / Motivation

Fixes #2203.

RichText in sitecore-jss-react built its dangerouslySetInnerHTML prop inline on every render, creating a new object reference each time the component function ran — even when the rendered html was unchanged.

React DOM compares the dangerouslySetInnerHTML prop by reference (not by the nested __html string) for plain host elements, and unconditionally re-sets domElement.innerHTML when the reference changes. As a result, every parent re-render forced a full innerHTML replacement, destroying and recreating every child DOM node inside the rich text.

This is especially problematic in combination with sitecore-jss-nextjs's NextRichText, which attaches internal-link click/prefetch listeners imperatively via a useEffect keyed only on hasText. Since hasText doesn't change across re-renders, the effect never reruns — so after any unrelated ancestor re-render, the freshly recreated <a> nodes silently lose their SPA routing/prefetch behavior.

Symptoms:

  • Internal links inside rich text lose SPA routing/prefetch after any unrelated parent re-render (full page load instead of client-side navigation).
  • DOM nodes replaced mid-interaction (e.g. between mousedown and click) break clicks.
  • Needless perf cost: full innerHTML rewrite on every incidental re-render.

Fix

Memoize the dangerouslySetInnerHTML object in RichText with useMemo, keyed on the resolved html string (field.editable/field.value depending on editable). The object reference now stays stable across re-renders when content is unchanged — including when the field object itself is a new instance with the same value — so React DOM skips the innerHTML re-set and existing DOM nodes (and their listeners) are preserved. When the html actually changes, behavior is unchanged: content is re-rendered as before.

The memoization is placed before the early null return to comply with the Rules of Hooks.

Test-infrastructure change (sitecore-jss-nextjs/test/setup.js)

This is the first hook used by a sitecore-jss-react component that sitecore-jss-nextjs unit tests render. Because the monorepo uses nmHoistingLimits: workspaces, each workspace resolves its own physical copy of react — so the workspace-linked sitecore-jss-react dist ran its hooks against a different React instance than the react-dom rendering the tests, throwing TypeError: Cannot read properties of null (reading 'useMemo').

The mocha setup now redirects react/react-dom resolution to a single copy for the test run. This affects unit tests only; consuming apps have a single React via peer dependencies and are unaffected.

Changes

  • packages/sitecore-jss-react/src/components/RichText.tsx — memoize dangerouslySetInnerHTML keyed on the resolved html string.
  • packages/sitecore-jss-react/src/components/RichText.test.tsx — new tests: DOM content is not replaced on re-render with an unchanged value (same or new field object), and is replaced when the value changes.
  • packages/sitecore-jss-nextjs/test/setup.js — dedupe react/react-dom module resolution in the mocha test environment (see above).

Testing Details

  • New test reproducing the bug fails without the fix (AssertionError: expected <a href="/foo"></a> to equal <a href="/foo"></a> — anchor node replaced on re-render) and passes with it. A second test verifies the DOM is replaced when the field value actually changes.

  • packages/sitecore-jss-react: yarn test — 288 passing, yarn lint clean, yarn build clean.

  • packages/sitecore-jss-nextjs: yarn test — 324 passing, yarn lint clean.

  • Unit Test Added

  • Manual Test/Other (Please elaborate)

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

🤖 Generated with Claude Code

…ence across re-renders

RichText built a new dangerouslySetInnerHTML object on every render. React DOM
compares this prop by reference, so every parent re-render re-set innerHTML,
recreating all child DOM nodes and dropping the internal-link click/prefetch
listeners attached by NextRichText.

Memoize the object keyed on the resolved html string so the reference stays
stable when content is unchanged.

Also dedupe react/react-dom in sitecore-jss-nextjs test setup: with yarn
nmHoistingLimits: workspaces, the workspace-linked react package resolves its
own React copy, breaking hooks in its components under this package's react-dom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pzi
pzi force-pushed the fix/react-richtext-stable-innerhtml branch from b1b25ce to 8d5080b Compare July 4, 2026 18:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RichText builds a new dangerouslySetInnerHTML object every render, causing full DOM replacement and dropped internal-link listeners

1 participant