Skip to content

Commit 64cb617

Browse files
authored
StreamlitMarkdown - module level plugin cache (#13152)
React's `StrictMode` exposed some opportunities to improve the recently added lazy loading for `StreamlitMarkdown`'s plugins:`rehype-katex`,`rehype-raw`, & `remark-emoji` Component is now StrictMode compatible, with improved performance (plugins load once per app, not per component instance), and better handle plugin load failure (render as plain text). This PR better makes the following changes: * **Module-level caching**: Moved plugin storage outside React's component lifecycle to share across instances and persist across re-renders * **Adds custom `useLazyPlugin` hook**: Consolidated three nearly-identical useEffect blocks into a single reusable hook with proper cleanup handling * **Extraction to `utils.ts`**: Moved all plugin-related logic (types, loaders, cache, helpers, hook) to a dedicated file
1 parent 23b9a8f commit 64cb617

File tree

3 files changed

+1000
-116
lines changed

3 files changed

+1000
-116
lines changed

frontend/lib/src/components/shared/StreamlitMarkdown/StreamlitMarkdown.tsx

Lines changed: 75 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,7 @@ import React, {
2525
Suspense,
2626
useCallback,
2727
useContext,
28-
useEffect,
2928
useMemo,
30-
useRef,
3129
useState,
3230
} from "react"
3331

@@ -73,26 +71,24 @@ import {
7371
StyledPreWrapper,
7472
StyledStreamlitMarkdown,
7573
} from "./styled-components"
74+
import {
75+
type EmojiPlugin,
76+
isLoadedPlugin,
77+
type KatexPlugin,
78+
loadKatexPlugin,
79+
loadKatexStyles,
80+
loadRehypeRaw,
81+
loadRemarkEmoji,
82+
type RawPlugin,
83+
useLazyPlugin,
84+
wrapRehypePlugin,
85+
wrapRemarkPlugin,
86+
} from "./utils"
7687

7788
const StreamlitSyntaxHighlighter = lazy(
7889
() => import("~lib/components/elements/CodeBlock/StreamlitSyntaxHighlighter")
7990
)
8091

81-
// Lazy load katex dependencies
82-
const loadKatexPlugin = (): Promise<typeof import("rehype-katex")> =>
83-
import("rehype-katex")
84-
const loadKatexStyles = once((): void => {
85-
void import("katex/dist/katex.min.css")
86-
})
87-
88-
// Lazy load rehype-raw (pulls in parse5)
89-
const loadRehypeRaw = (): Promise<typeof import("rehype-raw")> =>
90-
import("rehype-raw")
91-
92-
// Lazy load remark-emoji (pulls in node-emoji and @sindresorhus/is)
93-
const loadRemarkEmoji = (): Promise<typeof import("remark-emoji")> =>
94-
import("remark-emoji")
95-
9692
/**
9793
* Heuristic to determine if the markdown source contains emoji shortcodes that require remark-emoji.
9894
* Checks for patterns like :emoji_name: but excludes Streamlit's custom :material/ and
@@ -847,96 +843,60 @@ export const RenderedMarkdown = memo(function RenderedMarkdown({
847843
disableLinks,
848844
}: Readonly<RenderedMarkdownProps>): ReactElement {
849845
const theme = useEmotionTheme()
850-
type KatexPlugin = Awaited<ReturnType<typeof loadKatexPlugin>>["default"]
851-
type RawPlugin = Awaited<ReturnType<typeof loadRehypeRaw>>["default"]
852-
type EmojiPlugin = Awaited<ReturnType<typeof loadRemarkEmoji>>["default"]
853-
const [katexPlugin, setKatexPlugin] = useState<KatexPlugin | null>(null)
854-
const isLoadingKatexRef = useRef(false)
855-
const [rawPlugin, setRawPlugin] = useState<RawPlugin | null>(null)
856-
const isLoadingRawRef = useRef(false)
857-
const [emojiPlugin, setEmojiPlugin] = useState<EmojiPlugin | null>(null)
858-
const isLoadingEmojiRef = useRef(false)
859846

860847
const needsKatex = useMemo(() => containsMathSyntax(source), [source])
861848
const needsEmoji = useMemo(() => containsEmojiShortcodes(source), [source])
862849

863-
// Load katex plugin when needed
864-
useEffect(() => {
865-
let isMounted = true
866-
867-
if (needsKatex && !katexPlugin && !isLoadingKatexRef.current) {
868-
isLoadingKatexRef.current = true
869-
loadKatexStyles()
870-
void loadKatexPlugin()
871-
.then(module => {
872-
if (isMounted) {
873-
setKatexPlugin(() => module.default)
874-
}
875-
})
876-
.catch(() => {
877-
// Silently fail - math will render as plain text
878-
})
879-
.finally(() => {
880-
isLoadingKatexRef.current = false
881-
})
882-
}
850+
// Lazy load plugins only when needed
851+
const katexPlugin = useLazyPlugin<KatexPlugin>({
852+
key: "katex",
853+
needed: needsKatex,
854+
load: loadKatexPlugin,
855+
pluginName: "rehype-katex",
856+
onBeforeLoad: loadKatexStyles,
857+
})
858+
859+
const rawPlugin = useLazyPlugin<RawPlugin>({
860+
key: "raw",
861+
needed: allowHTML,
862+
load: loadRehypeRaw,
863+
pluginName: "rehype-raw",
864+
})
865+
866+
const emojiPlugin = useLazyPlugin<EmojiPlugin>({
867+
key: "emoji",
868+
needed: needsEmoji,
869+
load: loadRemarkEmoji,
870+
pluginName: "remark-emoji",
871+
})
883872

884-
return () => {
885-
isMounted = false
886-
}
887-
}, [needsKatex, katexPlugin])
888-
889-
// Load rehype-raw plugin when HTML is allowed
890-
useEffect(() => {
891-
let isMounted = true
892-
893-
if (allowHTML && !rawPlugin && !isLoadingRawRef.current) {
894-
isLoadingRawRef.current = true
895-
void loadRehypeRaw()
896-
.then(module => {
897-
if (isMounted) {
898-
setRawPlugin(() => module.default)
899-
}
900-
})
901-
.catch(() => {
902-
// Silently fail - HTML will be escaped
903-
})
904-
.finally(() => {
905-
isLoadingRawRef.current = false
906-
})
907-
}
873+
const colorMapping = useMemo(() => createColorMapping(theme), [theme])
908874

909-
return () => {
910-
isMounted = false
911-
}
912-
}, [allowHTML, rawPlugin])
913-
914-
// Load remark-emoji plugin when emoji shortcodes are detected
915-
useEffect(() => {
916-
let isMounted = true
917-
918-
if (needsEmoji && !emojiPlugin && !isLoadingEmojiRef.current) {
919-
isLoadingEmojiRef.current = true
920-
void loadRemarkEmoji()
921-
.then(module => {
922-
if (isMounted) {
923-
setEmojiPlugin(() => module.default)
924-
}
925-
})
926-
.catch(() => {
927-
// Silently fail - emoji shortcodes will render as plain text
928-
})
929-
.finally(() => {
930-
isLoadingEmojiRef.current = false
931-
})
932-
}
875+
// Wrap plugins once when they load, not on every render or when other deps change
876+
const wrappedKatexPlugin = useMemo(
877+
() =>
878+
isLoadedPlugin(katexPlugin)
879+
? wrapRehypePlugin(katexPlugin, "rehype-katex")
880+
: null,
881+
[katexPlugin]
882+
)
933883

934-
return () => {
935-
isMounted = false
936-
}
937-
}, [needsEmoji, emojiPlugin])
884+
const wrappedRawPlugin = useMemo(
885+
() =>
886+
isLoadedPlugin(rawPlugin)
887+
? wrapRehypePlugin(rawPlugin, "rehype-raw")
888+
: null,
889+
[rawPlugin]
890+
)
938891

939-
const colorMapping = useMemo(() => createColorMapping(theme), [theme])
892+
const wrappedEmojiPlugin = useMemo(
893+
() =>
894+
isLoadedPlugin(emojiPlugin)
895+
? // eslint-disable-next-line @typescript-eslint/no-explicit-any -- unified's Plugin type is more complex than our wrapper expects
896+
wrapRemarkPlugin(emojiPlugin as any, "remark-emoji")
897+
: null,
898+
[emojiPlugin]
899+
)
940900

941901
const remarkPlugins = useMemo<PluggableList>(() => {
942902
const plugins: PluggableList = [
@@ -945,33 +905,30 @@ export const RenderedMarkdown = memo(function RenderedMarkdown({
945905
createRemarkMaterialIcons(theme),
946906
]
947907

948-
// Only add emoji plugin if it's loaded (lazy-loaded when emoji shortcodes detected)
949-
if (emojiPlugin) {
950-
plugins.push(emojiPlugin)
908+
if (needsEmoji && wrappedEmojiPlugin) {
909+
plugins.push(wrappedEmojiPlugin)
951910
}
952911

953912
return plugins
954-
}, [theme, colorMapping, emojiPlugin])
913+
}, [theme, colorMapping, needsEmoji, wrappedEmojiPlugin])
955914

956915
const rehypePlugins = useMemo<PluggableList>(() => {
957916
const plugins: PluggableList = []
958917

959-
// Only add katex plugin if it's loaded
960-
if (katexPlugin) {
961-
plugins.push(katexPlugin)
918+
if (needsKatex && wrappedKatexPlugin) {
919+
plugins.push(wrappedKatexPlugin)
962920
}
963921

964-
// Only add raw plugin if it's loaded and HTML is allowed
965-
if (allowHTML && rawPlugin) {
966-
plugins.push(rawPlugin)
922+
if (allowHTML && wrappedRawPlugin) {
923+
plugins.push(wrappedRawPlugin)
967924
}
968925

969926
// This plugin must run last to ensure the inline property is set correctly
970927
// and not overwritten by other plugins like rehypeRaw
971928
plugins.push(rehypeSetCodeInlineProperty)
972929

973930
return plugins
974-
}, [allowHTML, katexPlugin, rawPlugin])
931+
}, [allowHTML, needsKatex, wrappedKatexPlugin, wrappedRawPlugin])
975932

976933
const renderers = useMemo(
977934
() =>
@@ -993,12 +950,14 @@ export const RenderedMarkdown = memo(function RenderedMarkdown({
993950
return disableLinks ? LINKS_DISALLOWED_ELEMENTS : LABEL_DISALLOWED_ELEMENTS
994951
}, [isLabel, disableLinks])
995952

996-
// Show skeleton while dependencies are loading
997-
if (
998-
(needsKatex && !katexPlugin) ||
999-
(allowHTML && !rawPlugin) ||
1000-
(needsEmoji && !emojiPlugin)
1001-
) {
953+
// Show skeleton while required plugins are still loading
954+
// A plugin is "loading" if it's needed but state is still null (not loaded, not failed)
955+
const isLoadingPlugins =
956+
(needsKatex && katexPlugin === null) ||
957+
(allowHTML && rawPlugin === null) ||
958+
(needsEmoji && emojiPlugin === null)
959+
960+
if (isLoadingPlugins) {
1002961
return (
1003962
<ErrorBoundary>
1004963
<Skeleton

0 commit comments

Comments
 (0)