Skip to content

Commit 79f7995

Browse files
committed
perf(emcn): defer prismjs in Code so it's off the shared barrel's static graph
1 parent da1efea commit 79f7995

3 files changed

Lines changed: 130 additions & 19 deletions

File tree

apps/sim/components/emcn/components/code/code.tsx

Lines changed: 110 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,104 @@ import {
1212
} from 'react'
1313
import { useVirtualizer } from '@tanstack/react-virtual'
1414
import { ChevronRight } from 'lucide-react'
15-
import { highlight, languages } from 'prismjs'
16-
import 'prismjs/components/prism-javascript'
17-
import 'prismjs/components/prism-python'
18-
import 'prismjs/components/prism-json'
1915
import { cn } from '@/lib/core/utils/cn'
2016
import './code.css'
2117

2218
/**
23-
* Re-export Prism.js highlighting utilities for use across the app.
24-
* Components can import these instead of importing from prismjs directly.
19+
* Shape of the lazily-loaded Prism module (`./prism`), narrowed to the two
20+
* members this component uses for highlighting.
2521
*/
26-
export { highlight, languages }
22+
type PrismModule = typeof import('./prism')
23+
24+
/**
25+
* Module-level singleton promise for the lazily-loaded Prism module.
26+
*
27+
* Prism (core + the side-effectful JS/Python/JSON grammar registrations) is kept
28+
* out of this module's static import graph so it never lands in bundles that only
29+
* pull `Code` through the shared `@/components/emcn` barrel. It is loaded once per
30+
* session on the first highlight and cached here for all subsequent viewers.
31+
*/
32+
let prismModulePromise: Promise<PrismModule> | null = null
33+
34+
/**
35+
* The resolved Prism module, cached synchronously once the first load settles so
36+
* later viewers can initialize from it without a null→loaded render cycle.
37+
*/
38+
let resolvedPrism: PrismModule | null = null
39+
40+
/**
41+
* Loads the Prism module once and caches both the in-flight promise and the
42+
* resolved module for reuse.
43+
*
44+
* @returns A promise resolving to the Prism highlighting utilities.
45+
*/
46+
function loadPrism(): Promise<PrismModule> {
47+
if (!prismModulePromise) {
48+
prismModulePromise = import('./prism').then((mod) => {
49+
resolvedPrism = mod
50+
return mod
51+
})
52+
}
53+
return prismModulePromise
54+
}
55+
56+
/**
57+
* Subscribes a client component to the lazily-loaded Prism module.
58+
*
59+
* Seeds from {@link resolvedPrism} so viewers mounted after the first load
60+
* highlight synchronously; otherwise returns `null` until Prism resolves (so
61+
* callers render the plaintext fallback), then the loaded module.
62+
*
63+
* @returns The loaded Prism module, or `null` while loading.
64+
*/
65+
function usePrism(): PrismModule | null {
66+
const [prism, setPrism] = useState<PrismModule | null>(resolvedPrism)
67+
68+
useEffect(() => {
69+
if (prism) return
70+
let active = true
71+
loadPrism().then((mod) => {
72+
if (active) setPrism(mod)
73+
})
74+
return () => {
75+
active = false
76+
}
77+
}, [prism])
78+
79+
return prism
80+
}
81+
82+
/**
83+
* Escapes HTML special characters so raw code can be safely injected via
84+
* `dangerouslySetInnerHTML` as the plaintext fallback before Prism loads (and
85+
* for unknown languages). Matches Prism's own escaping for visual parity.
86+
*
87+
* @param text - The raw code text to escape
88+
* @returns The HTML-escaped text
89+
*/
90+
function escapeHtml(text: string): string {
91+
return text
92+
.replace(/&/g, '&amp;')
93+
.replace(/</g, '&lt;')
94+
.replace(/>/g, '&gt;')
95+
.replace(/"/g, '&quot;')
96+
.replace(/'/g, '&#39;')
97+
}
98+
99+
/**
100+
* Highlights a single line of code, falling back to escaped plaintext when Prism
101+
* has not loaded yet or the language grammar is unavailable.
102+
*
103+
* @param prism - The loaded Prism module, or `null` while loading
104+
* @param text - The line of code to highlight
105+
* @param language - The language key (e.g. `json`, `javascript`, `python`)
106+
* @returns Highlighted HTML, or escaped plaintext as a fallback
107+
*/
108+
function highlightOrEscape(prism: PrismModule | null, text: string, language: string): string {
109+
if (!prism) return escapeHtml(text)
110+
const grammar = prism.languages[language] || prism.languages.javascript
111+
return prism.highlight(text, grammar, language)
112+
}
27113

28114
/**
29115
* Code editor configuration and constants.
@@ -844,6 +930,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
844930
const containerRef = useRef<HTMLDivElement>(null)
845931
const scrollRef = useRef<HTMLDivElement>(null)
846932
const [containerHeight, setContainerHeight] = useState(400)
933+
const prism = usePrism()
847934

848935
const lines = useMemo(() => code.split('\n'), [code])
849936
const gutterWidth = useMemo(() => calculateGutterWidth(lines.length), [lines.length])
@@ -890,11 +977,10 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
890977

891978
// Only process visible lines for efficiency (not all lines)
892979
const visibleLines = useMemo(() => {
893-
const lang = languages[language] || languages.javascript
894980
const hasSearch = searchQuery?.trim()
895981

896982
return visibleLineIndices.map((idx) => {
897-
let html = highlight(displayLines[idx], lang, language)
983+
let html = highlightOrEscape(prism, displayLines[idx], language)
898984

899985
if (hasSearch && searchQuery) {
900986
const result = applySearchHighlightingToLine(
@@ -908,7 +994,15 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
908994

909995
return { lineNumber: idx + 1, html }
910996
})
911-
}, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex, matchOffsets])
997+
}, [
998+
prism,
999+
displayLines,
1000+
language,
1001+
visibleLineIndices,
1002+
searchQuery,
1003+
currentMatchIndex,
1004+
matchOffsets,
1005+
])
9121006

9131007
const hasCollapsibleContent = collapsibleLines.size > 0
9141008
const effectiveShowCollapseColumn = showCollapseColumn && hasCollapsibleContent
@@ -1037,6 +1131,7 @@ const ViewerInner = memo(function ViewerInner({
10371131
contentRef,
10381132
showCollapseColumn,
10391133
}: ViewerInnerProps) {
1134+
const prism = usePrism()
10401135
const lines = useMemo(() => code.split('\n'), [code])
10411136
const gutterWidth = useMemo(() => calculateGutterWidth(lines.length), [lines.length])
10421137

@@ -1083,22 +1178,21 @@ const ViewerInner = memo(function ViewerInner({
10831178

10841179
// Pre-compute highlighted lines with search for visible indices (for gutter mode)
10851180
const highlightedVisibleLines = useMemo(() => {
1086-
const lang = languages[language] || languages.javascript
1087-
10881181
if (!searchQuery?.trim()) {
10891182
return visibleLineIndices.map((idx) => ({
10901183
lineNumber: idx + 1,
1091-
html: highlight(displayLines[idx], lang, language) || '&nbsp;',
1184+
html: highlightOrEscape(prism, displayLines[idx], language) || '&nbsp;',
10921185
}))
10931186
}
10941187

10951188
return visibleLineIndices.map((idx) => {
1096-
let html = highlight(displayLines[idx], lang, language)
1189+
let html = highlightOrEscape(prism, displayLines[idx], language)
10971190
const matchCounter = { count: cumulativeMatches[idx] }
10981191
html = applySearchHighlighting(html, searchQuery, currentMatchIndex, matchCounter)
10991192
return { lineNumber: idx + 1, html: html || '&nbsp;' }
11001193
})
11011194
}, [
1195+
prism,
11021196
displayLines,
11031197
language,
11041198
visibleLineIndices,
@@ -1109,16 +1203,15 @@ const ViewerInner = memo(function ViewerInner({
11091203

11101204
// Pre-compute simple highlighted code (for no-gutter mode)
11111205
const highlightedCode = useMemo(() => {
1112-
const lang = languages[language] || languages.javascript
11131206
const visibleCode = visibleLineIndices.map((idx) => displayLines[idx]).join('\n')
1114-
let html = highlight(visibleCode, lang, language)
1207+
let html = highlightOrEscape(prism, visibleCode, language)
11151208

11161209
if (searchQuery?.trim()) {
11171210
const matchCounter = { count: 0 }
11181211
html = applySearchHighlighting(html, searchQuery, currentMatchIndex, matchCounter)
11191212
}
11201213
return html
1121-
}, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex])
1214+
}, [prism, displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex])
11221215

11231216
const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
11241217

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { highlight, languages } from 'prismjs'
2+
import 'prismjs/components/prism-javascript'
3+
import 'prismjs/components/prism-python'
4+
import 'prismjs/components/prism-json'
5+
6+
/**
7+
* Prism.js highlighting utilities isolated in a dedicated module.
8+
*
9+
* The grammar imports above are side-effectful (they register languages on the
10+
* shared `Prism.languages` registry), which marks any module that statically
11+
* imports them as having side effects and therefore non-tree-shakeable. Keeping
12+
* them here — rather than in `code.tsx` — ensures Prism only enters bundles that
13+
* actually import `highlight`/`languages`, instead of every consumer of the
14+
* shared `@/components/emcn` barrel (which re-exports `Code`).
15+
*
16+
* `code.tsx` itself never imports this module statically; it loads it lazily via
17+
* dynamic `import()` on first highlight.
18+
*/
19+
export { highlight, languages }

apps/sim/components/emcn/components/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,9 @@ export {
7676
Code,
7777
calculateGutterWidth,
7878
getCodeEditorProps,
79-
highlight,
80-
languages,
8179
} from './code/code'
8280
export { CopyCodeButton } from './code/copy-code-button'
81+
export { highlight, languages } from './code/prism'
8382
export { CollapsibleCard, type CollapsibleCardProps } from './collapsible-card/collapsible-card'
8483
export {
8584
Combobox,

0 commit comments

Comments
 (0)