Skip to content

Commit 8a66f03

Browse files
committed
Make markdown rendering load async
1 parent 4a32bd2 commit 8a66f03

8 files changed

Lines changed: 308 additions & 266 deletions

File tree

jest.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ module.exports = {
3030
modulePathIgnorePatterns: [
3131
'<rootDir>/packages/*.*/dist/*.*',
3232
'<rootDir>/packages/*.*/public/*.*',
33-
'<rootDir>/packages/*.*/.cache/*.*'
33+
'<rootDir>/packages/*.*/.cache/*.*',
34+
'<rootDir>/packages/tree-shaking-demo/*.*'
3435
],
3536
moduleNameMapper: {
3637
'\\.(css|scss)$': 'identity-obj-proxy',

packages/module/src/DeepThinking/DeepThinking.test.tsx

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33
import '@testing-library/jest-dom';
44
import DeepThinking from './DeepThinking';
5+
import { expectBoldMarkdownText } from '../test-utils/markdownTestUtils';
56

67
describe('DeepThinking', () => {
78
const defaultProps = {
@@ -120,11 +121,10 @@ describe('DeepThinking', () => {
120121
expect(screen.getByText('Thinking content')).not.toBeVisible();
121122
});
122123

123-
it('should render toggleContent as markdown when isToggleContentMarkdown is true', () => {
124+
it('should render toggleContent as markdown when isToggleContentMarkdown is true', async () => {
124125
const toggleContent = '**Bold thinking**';
125-
const { container } = render(<DeepThinking toggleContent={toggleContent} isToggleContentMarkdown />);
126-
expect(container.querySelector('strong')).toBeTruthy();
127-
expect(screen.getByText('Bold thinking')).toBeTruthy();
126+
render(<DeepThinking toggleContent={toggleContent} isToggleContentMarkdown />);
127+
await expectBoldMarkdownText('Bold thinking');
128128
});
129129

130130
it('should not render toggleContent as markdown when isToggleContentMarkdown is false', () => {
@@ -134,11 +134,10 @@ describe('DeepThinking', () => {
134134
expect(screen.getByText('**Bold thinking**')).toBeTruthy();
135135
});
136136

137-
it('should render subheading as markdown when isSubheadingMarkdown is true', () => {
137+
it('should render subheading as markdown when isSubheadingMarkdown is true', async () => {
138138
const subheading = '**Bold subheading**';
139-
const { container } = render(<DeepThinking {...defaultProps} subheading={subheading} isSubheadingMarkdown />);
140-
expect(container.querySelector('strong')).toBeTruthy();
141-
expect(screen.getByText('Bold subheading')).toBeTruthy();
139+
render(<DeepThinking {...defaultProps} subheading={subheading} isSubheadingMarkdown />);
140+
await expectBoldMarkdownText('Bold subheading');
142141
});
143142

144143
it('should not render subheading as markdown when isSubheadingMarkdown is false', () => {
@@ -147,11 +146,10 @@ describe('DeepThinking', () => {
147146
expect(screen.getByText('**Bold subheading**')).toBeTruthy();
148147
});
149148

150-
it('should render body as markdown when isBodyMarkdown is true', () => {
149+
it('should render body as markdown when isBodyMarkdown is true', async () => {
151150
const body = '**Bold body**';
152-
const { container } = render(<DeepThinking {...defaultProps} body={body} isBodyMarkdown />);
153-
expect(container.querySelector('strong')).toBeTruthy();
154-
expect(screen.getByText('Bold body')).toBeTruthy();
151+
render(<DeepThinking {...defaultProps} body={body} isBodyMarkdown />);
152+
await expectBoldMarkdownText('Bold body');
155153
});
156154

157155
it('should not render body as markdown when isBodyMarkdown is false', () => {

packages/module/src/MarkdownContent/MarkdownContent.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react';
22
import '@testing-library/jest-dom';
33
import MarkdownContent from './MarkdownContent';
44
import rehypeExternalLinks from '../__mocks__/rehype-external-links';
5+
import { expectBoldMarkdownText } from '../test-utils/markdownTestUtils';
56

67
const BOLD_TEXT = '**Bold text**';
78
const ITALIC_TEXT = '*Italic text*';
@@ -37,10 +38,9 @@ describe('MarkdownContent', () => {
3738
jest.clearAllMocks();
3839
});
3940

40-
it('should render bold text correctly', () => {
41-
const { container } = render(<MarkdownContent content={BOLD_TEXT} />);
42-
expect(container.querySelector('strong')).toBeTruthy();
43-
expect(screen.getByText('Bold text')).toBeTruthy();
41+
it('should render bold text correctly', async () => {
42+
render(<MarkdownContent content={BOLD_TEXT} />);
43+
await expectBoldMarkdownText('Bold text');
4444
});
4545

4646
it('should render italic text correctly', () => {

packages/module/src/MarkdownContent/MarkdownContent.tsx

Lines changed: 12 additions & 218 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,18 @@
11
// ============================================================================
22
// Markdown Content - Shared component for rendering markdown
33
// ============================================================================
4-
import { type FunctionComponent, ReactNode } from 'react';
5-
import Markdown, { Options } from 'react-markdown';
6-
import remarkGfm from 'remark-gfm';
4+
import { lazy, Suspense, type FunctionComponent, type ReactNode } from 'react';
5+
import type { Options } from 'react-markdown';
76
import { ContentVariants } from '@patternfly/react-core';
8-
import CodeBlockMessage, { CodeBlockMessageProps } from '../Message/CodeBlockMessage/CodeBlockMessage';
7+
import type { CodeBlockMessageProps } from '../Message/CodeBlockMessage/CodeBlockMessage';
8+
import type { TableProps } from '@patternfly/react-table';
9+
import type { PluggableList } from 'unified';
10+
import type { ButtonProps } from '@patternfly/react-core';
911
import TextMessage from '../Message/TextMessage/TextMessage';
10-
import ListItemMessage from '../Message/ListMessage/ListItemMessage';
11-
import UnorderedListMessage from '../Message/ListMessage/UnorderedListMessage';
12-
import OrderedListMessage from '../Message/ListMessage/OrderedListMessage';
13-
import TableMessage from '../Message/TableMessage/TableMessage';
14-
import TrMessage from '../Message/TableMessage/TrMessage';
15-
import TdMessage from '../Message/TableMessage/TdMessage';
16-
import TbodyMessage from '../Message/TableMessage/TbodyMessage';
17-
import TheadMessage from '../Message/TableMessage/TheadMessage';
18-
import ThMessage from '../Message/TableMessage/ThMessage';
19-
import { TableProps } from '@patternfly/react-table';
20-
import ImageMessage from '../Message/ImageMessage/ImageMessage';
21-
import rehypeUnwrapImages from 'rehype-unwrap-images';
22-
import rehypeExternalLinks from 'rehype-external-links';
23-
import rehypeSanitize from 'rehype-sanitize';
24-
import rehypeHighlight from 'rehype-highlight';
25-
import 'highlight.js/styles/vs2015.css';
26-
import { PluggableList } from 'unified';
27-
import LinkMessage from '../Message/LinkMessage/LinkMessage';
28-
import { rehypeMoveImagesOutOfParagraphs } from '../Message/Plugins/rehypeMoveImagesOutOfParagraphs';
29-
import SuperscriptMessage from '../Message/SuperscriptMessage/SuperscriptMessage';
30-
import { ButtonProps } from '@patternfly/react-core';
3112
import { css } from '@patternfly/react-styles';
3213

14+
const MarkdownRenderer = lazy(() => import('./MarkdownRenderer'));
15+
3316
/**
3417
* MarkdownContent renders content either as plain text or with content with markdown support.
3518
*
@@ -69,32 +52,10 @@ export interface MarkdownContentProps {
6952
export const MarkdownContent: FunctionComponent<MarkdownContentProps> = ({
7053
content,
7154
isMarkdownDisabled,
72-
codeBlockProps,
73-
tableProps,
74-
openLinkInNewTab = true,
75-
additionalRehypePlugins = [],
76-
additionalRemarkPlugins = [],
77-
linkProps,
78-
reactMarkdownProps,
79-
remarkGfmProps,
80-
hasNoImages = false,
8155
isPrimary,
8256
textComponent,
83-
shouldRetainStyles
57+
...markdownProps
8458
}: MarkdownContentProps) => {
85-
let rehypePlugins: PluggableList = [rehypeUnwrapImages, rehypeMoveImagesOutOfParagraphs, rehypeHighlight];
86-
if (openLinkInNewTab) {
87-
rehypePlugins = rehypePlugins.concat([[rehypeExternalLinks, { target: '_blank' }, rehypeSanitize]]);
88-
}
89-
if (additionalRehypePlugins) {
90-
rehypePlugins.push(...additionalRehypePlugins);
91-
}
92-
93-
const disallowedElements = hasNoImages ? ['img'] : [];
94-
if (reactMarkdownProps && reactMarkdownProps.disallowedElements) {
95-
disallowedElements.push(...reactMarkdownProps.disallowedElements);
96-
}
97-
9859
if (isMarkdownDisabled) {
9960
if (textComponent) {
10061
return <>{textComponent}</>;
@@ -107,176 +68,9 @@ export const MarkdownContent: FunctionComponent<MarkdownContentProps> = ({
10768
}
10869

10970
return (
110-
<Markdown
111-
components={{
112-
section: (props) => {
113-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
114-
const { node, ...rest } = props;
115-
return (
116-
<section
117-
{...rest}
118-
className={css('pf-chatbot__message-text', shouldRetainStyles && 'pf-m-markdown', rest?.className)}
119-
/>
120-
);
121-
},
122-
p: (props) => {
123-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
124-
const { node, ...rest } = props;
125-
return (
126-
<TextMessage
127-
shouldRetainStyles={shouldRetainStyles}
128-
component={ContentVariants.p}
129-
{...rest}
130-
isPrimary={isPrimary}
131-
/>
132-
);
133-
},
134-
code: ({ children, ...props }) => {
135-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
136-
const { node, ...codeProps } = props;
137-
return (
138-
<CodeBlockMessage
139-
{...codeProps}
140-
{...codeBlockProps}
141-
isPrimary={isPrimary}
142-
shouldRetainStyles={shouldRetainStyles}
143-
>
144-
{children}
145-
</CodeBlockMessage>
146-
);
147-
},
148-
h1: (props) => {
149-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
150-
const { node, ...rest } = props;
151-
return <TextMessage shouldRetainStyles={shouldRetainStyles} component={ContentVariants.h1} {...rest} />;
152-
},
153-
h2: (props) => {
154-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
155-
const { node, ...rest } = props;
156-
return <TextMessage shouldRetainStyles={shouldRetainStyles} component={ContentVariants.h2} {...rest} />;
157-
},
158-
h3: (props) => {
159-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
160-
const { node, ...rest } = props;
161-
return <TextMessage shouldRetainStyles={shouldRetainStyles} component={ContentVariants.h3} {...rest} />;
162-
},
163-
h4: (props) => {
164-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
165-
const { node, ...rest } = props;
166-
return <TextMessage shouldRetainStyles={shouldRetainStyles} component={ContentVariants.h4} {...rest} />;
167-
},
168-
h5: (props) => {
169-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
170-
const { node, ...rest } = props;
171-
return <TextMessage shouldRetainStyles={shouldRetainStyles} component={ContentVariants.h5} {...rest} />;
172-
},
173-
h6: (props) => {
174-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
175-
const { node, ...rest } = props;
176-
return <TextMessage shouldRetainStyles={shouldRetainStyles} component={ContentVariants.h6} {...rest} />;
177-
},
178-
blockquote: (props) => {
179-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
180-
const { node, ...rest } = props;
181-
return (
182-
<TextMessage shouldRetainStyles={shouldRetainStyles} component={ContentVariants.blockquote} {...rest} />
183-
);
184-
},
185-
ul: (props) => {
186-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
187-
const { node, ...rest } = props;
188-
return <UnorderedListMessage shouldRetainStyles={shouldRetainStyles} {...rest} />;
189-
},
190-
ol: (props) => {
191-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
192-
const { node, ...rest } = props;
193-
return <OrderedListMessage shouldRetainStyles={shouldRetainStyles} {...rest} />;
194-
},
195-
li: (props) => {
196-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
197-
const { node, ...rest } = props;
198-
return <ListItemMessage {...rest} />;
199-
},
200-
// table requires node attribute for calculating headers for mobile breakpoint
201-
table: (props) => (
202-
<TableMessage shouldRetainStyles={shouldRetainStyles} {...props} {...tableProps} isPrimary={isPrimary} />
203-
),
204-
tbody: (props) => {
205-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
206-
const { node, ...rest } = props;
207-
return <TbodyMessage {...rest} />;
208-
},
209-
thead: (props) => {
210-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
211-
const { node, ...rest } = props;
212-
return <TheadMessage {...rest} />;
213-
},
214-
tr: (props) => {
215-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
216-
const { node, ...rest } = props;
217-
return <TrMessage {...rest} />;
218-
},
219-
td: (props) => {
220-
// Conflicts with Td type
221-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
222-
const { node, width, ...rest } = props;
223-
return <TdMessage {...rest} />;
224-
},
225-
th: (props) => {
226-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
227-
const { node, ...rest } = props;
228-
return <ThMessage {...rest} />;
229-
},
230-
img: (props) => {
231-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
232-
const { node, ...rest } = props;
233-
return <ImageMessage {...rest} />;
234-
},
235-
a: (props) => {
236-
// node is just the details of the document structure - not needed
237-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
238-
const { node, ...rest } = props;
239-
return (
240-
// some a types conflict with ButtonProps, but it's ok because we are using an a tag
241-
// there are too many to handle manually
242-
<LinkMessage shouldRetainStyles={shouldRetainStyles} {...(rest as any)} {...linkProps}>
243-
{props.children}
244-
</LinkMessage>
245-
);
246-
},
247-
// used for footnotes
248-
sup: (props) => {
249-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
250-
const { node, ...rest } = props;
251-
return <SuperscriptMessage {...rest} />;
252-
}
253-
}}
254-
remarkPlugins={[[remarkGfm, { ...remarkGfmProps }], ...additionalRemarkPlugins]}
255-
rehypePlugins={rehypePlugins}
256-
{...reactMarkdownProps}
257-
remarkRehypeOptions={{
258-
// removes sr-only class from footnote labels applied by default
259-
footnoteLabelProperties: { className: [''] },
260-
// omit default ↩ text; backref icon is rendered in LinkMessage
261-
footnoteBackContent: (_referenceIndex, rereferenceIndex) => {
262-
if (rereferenceIndex > 1) {
263-
return [
264-
{
265-
type: 'element',
266-
tagName: 'sup',
267-
properties: {},
268-
children: [{ type: 'text', value: String(rereferenceIndex) }]
269-
}
270-
];
271-
}
272-
return [];
273-
},
274-
...reactMarkdownProps?.remarkRehypeOptions
275-
}}
276-
disallowedElements={disallowedElements}
277-
>
278-
{content}
279-
</Markdown>
71+
<Suspense fallback={<span className={css('pf-chatbot__message-text')}>{content}</span>}>
72+
<MarkdownRenderer {...markdownProps} content={content} isPrimary={isPrimary} />
73+
</Suspense>
28074
);
28175
};
28276

0 commit comments

Comments
 (0)