Skip to content

Commit 5eaa04c

Browse files
heiskrCopilot
andauthored
Add RenderedHTML foundation for removing dangerouslySetInnerHTML (#61956)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 90cf8f1 commit 5eaa04c

8 files changed

Lines changed: 240 additions & 89 deletions

File tree

package-lock.json

Lines changed: 43 additions & 48 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@
212212
"flat": "^6.0.1",
213213
"github-slugger": "^2.0.0",
214214
"glob": "13.0.2",
215+
"hast-util-from-html": "^2.0.3",
215216
"hast-util-from-parse5": "^8.0.3",
216217
"hast-util-to-jsx-runtime": "^2.3.6",
217218
"hast-util-to-string": "^3.0.1",

src/frame/components/ui/MarkdownContent/MarkdownContent.tsx

Lines changed: 3 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
import { memo, ReactNode } from 'react'
2-
import type { ComponentProps, JSX } from 'react'
2+
import type { JSX } from 'react'
33
import { Fragment, jsx, jsxs } from 'react/jsx-runtime'
4-
import { toJsxRuntime, type Components } from 'hast-util-to-jsx-runtime'
4+
import { toJsxRuntime } from 'hast-util-to-jsx-runtime'
55
import type { Root as HastRoot } from 'hast'
66
import cx from 'classnames'
77

8-
import { CopyButton } from '@/frame/components/CopyButton'
9-
import { CodeTabsGroup } from '@/frame/components/CodeTabsGroup'
10-
import { ToggleableContent } from '@/tools/components/ToggleableContent'
11-
import { isToggleClass } from '@/tools/components/SelectionContext'
8+
import { markdownComponents } from './markdownComponents'
129
import styles from './MarkdownContent.module.scss'
1310

1411
export type MarkdownContentPropsT = {
@@ -18,41 +15,6 @@ export type MarkdownContentPropsT = {
1815
as?: keyof JSX.IntrinsicElements
1916
}
2017

21-
// Map specific elements in the HTML AST to interactive React components instead
22-
// of inert markup enhanced by post-hydration DOM mutation (#6619). This only
23-
// applies to the hast rendering path; the dangerouslySetInnerHTML string path is
24-
// untouched. The same map runs during SSR and on the client, so hydration matches.
25-
const markdownComponents = {
26-
button(props: ComponentProps<'button'>) {
27-
const classes = String(props.className || '').split(/\s+/)
28-
if (classes.includes('js-btn-copy')) {
29-
return <CopyButton {...props} />
30-
}
31-
return <button {...props} />
32-
},
33-
// Platform/tool-scoped blocks and inline spans subscribe to SelectionContext so
34-
// the pickers can hide non-matching content via React state instead of the old
35-
// imperative `style.display` DOM mutation (#6619). The className check is cheap
36-
// and runs first, so only the handful of toggleable elements become context
37-
// consumers; every other div/span renders as a plain element with no hook.
38-
div(props: ComponentProps<'div'>) {
39-
const classes = String(props.className || '').split(/\s+/)
40-
if (classes.includes('ghd-codetabs')) {
41-
return <CodeTabsGroup {...props} />
42-
}
43-
if (isToggleClass(props.className)) {
44-
return <ToggleableContent tag="div" {...props} />
45-
}
46-
return <div {...props} />
47-
},
48-
span(props: ComponentProps<'span'>) {
49-
if (isToggleClass(props.className)) {
50-
return <ToggleableContent tag="span" {...props} />
51-
}
52-
return <span {...props} />
53-
},
54-
} as unknown as Partial<Components>
55-
5618
// Memoized so that re-renders of the parent (e.g. when ToolPicker/PlatformPicker
5719
// state updates) don't cause React 19 to re-apply `dangerouslySetInnerHTML` and
5820
// wipe out the inline `display` styles set imperatively by the pickers.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { ComponentProps } from 'react'
2+
import type { Components } from 'hast-util-to-jsx-runtime'
3+
4+
import { CopyButton } from '@/frame/components/CopyButton'
5+
import { CodeTabsGroup } from '@/frame/components/CodeTabsGroup'
6+
import { ToggleableContent } from '@/tools/components/ToggleableContent'
7+
import { isToggleClass } from '@/tools/components/SelectionContext'
8+
9+
// Map specific elements in the HTML AST to interactive React components instead
10+
// of inert markup enhanced by post-hydration DOM mutation (#6619). Shared by
11+
// every hast rendering path (the article body via MarkdownContent and the
12+
// HTML-string fragments via RenderedHTML) so the same element always becomes the
13+
// same component, and SSR/client hydration stay in sync.
14+
//
15+
// The className checks are cheap and run first, so only the handful of
16+
// interactive/toggleable elements become components; every other div/span/button
17+
// renders as a plain element with no hook. Unrecognized classes fall through to
18+
// plain elements, so fragments that never contain pickers or copy buttons (e.g.
19+
// GraphQL/REST descriptions) are unaffected.
20+
export const markdownComponents = {
21+
button(props: ComponentProps<'button'>) {
22+
const classes = String(props.className || '').split(/\s+/)
23+
if (classes.includes('js-btn-copy')) {
24+
return <CopyButton {...props} />
25+
}
26+
return <button {...props} />
27+
},
28+
div(props: ComponentProps<'div'>) {
29+
const classes = String(props.className || '').split(/\s+/)
30+
if (classes.includes('ghd-codetabs')) {
31+
return <CodeTabsGroup {...props} />
32+
}
33+
if (isToggleClass(props.className)) {
34+
return <ToggleableContent tag="div" {...props} />
35+
}
36+
return <div {...props} />
37+
},
38+
span(props: ComponentProps<'span'>) {
39+
if (isToggleClass(props.className)) {
40+
return <ToggleableContent tag="span" {...props} />
41+
}
42+
return <span {...props} />
43+
},
44+
} as unknown as Partial<Components>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { useMemo } from 'react'
2+
import type { ComponentPropsWithoutRef, ElementType } from 'react'
3+
4+
import { markdownComponents } from '@/frame/components/ui/MarkdownContent/markdownComponents'
5+
import { renderHTMLString } from './render-html-string'
6+
7+
type RenderedHTMLOwnProps = {
8+
// A trusted HTML string (already produced by our own render pipeline or build
9+
// step) to render as real React elements. This string is rendered as markup,
10+
// so it MUST be trusted/sanitized upstream; React only escapes text nodes, not
11+
// the tags and attributes contained in this string.
12+
html: string
13+
}
14+
15+
export type RenderedHTMLProps<T extends ElementType = 'div'> = RenderedHTMLOwnProps & {
16+
// The wrapper element to render the parsed content inside. Defaults to a div;
17+
// pass e.g. 'span' for inline contexts or 'td' inside tables. Passthrough props
18+
// are typed against this element (e.g. `colSpan` is allowed when `as="td"`).
19+
as?: T
20+
} & Omit<ComponentPropsWithoutRef<T>, keyof RenderedHTMLOwnProps | 'as' | 'children'>
21+
22+
// Renders a trusted HTML string as real React elements instead of injecting it
23+
// as raw innerHTML (#6619). The string is parsed to hast and handed to the same
24+
// component map the article body uses, so React owns the resulting element tree.
25+
//
26+
// This is NOT a sanitizer. It carries the same trust assumption as
27+
// dangerouslySetInnerHTML: arbitrary tags/attributes in `html` are rendered, so
28+
// the string must already be trusted/sanitized upstream. The benefit over
29+
// dangerouslySetInnerHTML is that React owns the tree (no post-hydration DOM
30+
// mutation) and the move unblocks the react/no-danger lint guard, not added XSS
31+
// protection.
32+
//
33+
// Use this for the many call sites that hold a pre-rendered HTML fragment (e.g.
34+
// GraphQL/REST descriptions from build-time data, or request-time rendered
35+
// intros). For the markdown article body, prefer MarkdownContent with a `hast`
36+
// prop, which avoids the parse round-trip.
37+
export function RenderedHTML<T extends ElementType = 'div'>({
38+
html,
39+
as,
40+
...rest
41+
}: RenderedHTMLProps<T>) {
42+
const Component: ElementType = as || 'div'
43+
// `markdownComponents` is a module constant, so it is intentionally omitted
44+
// from the dependency list; only `html` changes the rendered output.
45+
const content = useMemo(() => renderHTMLString(html, markdownComponents), [html])
46+
47+
return <Component {...rest}>{content}</Component>
48+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { RenderedHTML } from './RenderedHTML'
2+
export type { RenderedHTMLProps } from './RenderedHTML'
3+
export { renderHTMLString } from './render-html-string'
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { ReactNode } from 'react'
2+
import { Fragment, jsx, jsxs } from 'react/jsx-runtime'
3+
import { toJsxRuntime, type Components } from 'hast-util-to-jsx-runtime'
4+
import { fromHtml } from 'hast-util-from-html'
5+
6+
// Parse a trusted HTML string into hast and render it as React elements (#6619).
7+
// Pure and dependency-light on purpose: it imports no UI components, so it can be
8+
// unit-tested without pulling in the styling/component chain. RenderedHTML wraps
9+
// this with the shared interactive component map.
10+
export function renderHTMLString(html: string, components?: Partial<Components>): ReactNode {
11+
return toJsxRuntime(fromHtml(html, { fragment: true }), {
12+
Fragment,
13+
jsx,
14+
jsxs,
15+
components,
16+
})
17+
}

0 commit comments

Comments
 (0)