Skip to content

Commit 003e459

Browse files
committed
refactor(ui): clean up remaining perf nit issues
1 parent 7853870 commit 003e459

7 files changed

Lines changed: 291 additions & 100 deletions

File tree

packages/ui/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const log = getLogger("actions")
5555

5656
const LazyFolderSelectionView = lazy(() => import("./components/folder-selection-view"))
5757
const LazyInstanceDisconnectedModal = lazy(() => import("./components/instance-disconnected-modal"))
58+
// Solid's lazy helper expects a default export, so wrap named exports explicitly.
5859
const LazySettingsScreen = lazy(() =>
5960
import("./components/settings-screen").then((module) => ({ default: module.SettingsScreen })),
6061
)

packages/ui/src/components/instance/instance-shell2.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const log = getLogger("session")
5959
const LazyInstanceWelcomeView = lazy(() => import("../instance-welcome-view"))
6060
const LazyInfoView = lazy(() => import("../info-view"))
6161
const LazyCommandPalette = lazy(() => import("../command-palette"))
62+
// Solid's lazy helper expects a default export, so wrap named exports explicitly.
6263
const LazyBackgroundProcessOutputDialog = lazy(() =>
6364
import("../background-process-output-dialog").then((module) => ({ default: module.BackgroundProcessOutputDialog })),
6465
)

packages/ui/src/components/markdown.tsx

Lines changed: 112 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
2-
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
32
import { useGlobalCache } from "../lib/hooks/use-global-cache"
43
import type { TextPart, RenderCache } from "../types/message"
54
import { getLogger } from "../lib/logger"
65
import { copyToClipboard } from "../lib/clipboard"
76
import { useI18n } from "../lib/i18n"
7+
import { escapeHtml } from "../lib/text-render-utils"
88

99
const log = getLogger("session")
1010

11+
type MarkdownModule = typeof import("../lib/markdown")
12+
13+
let markdownModulePromise: Promise<MarkdownModule> | null = null
14+
15+
function loadMarkdownModule(): Promise<MarkdownModule> {
16+
if (!markdownModulePromise) {
17+
markdownModulePromise = import("../lib/markdown")
18+
}
19+
return markdownModulePromise
20+
}
21+
1122
function hashText(value: string): string {
1223
let hash = 2166136261
1324
for (let index = 0; index < value.length; index++) {
@@ -24,6 +35,24 @@ function resolvePartVersion(part: TextPart, text: string): string {
2435
return `text-${hashText(text)}`
2536
}
2637

38+
function decodeHtmlEntitiesLocally(content: string): string {
39+
if (!content.includes("&") || typeof document === "undefined") {
40+
return content
41+
}
42+
43+
const textarea = document.createElement("textarea")
44+
textarea.innerHTML = content
45+
return textarea.value
46+
}
47+
48+
function renderFallbackHtml(content: string): string {
49+
if (!content) {
50+
return ""
51+
}
52+
53+
return escapeHtml(content).replace(/\n/g, "<br />")
54+
}
55+
2756
interface MarkdownProps {
2857
part: TextPart
2958
instanceId?: string
@@ -38,7 +67,8 @@ export function Markdown(props: MarkdownProps) {
3867
const { t } = useI18n()
3968
const [html, setHtml] = createSignal("")
4069
let containerRef: HTMLDivElement | undefined
41-
let latestRequestedText = ""
70+
let latestRequestKey = ""
71+
let cleanupLanguageListener: (() => void) | undefined
4272

4373
const notifyRendered = () => {
4474
Promise.resolve().then(() => props.onRendered?.())
@@ -47,15 +77,16 @@ export function Markdown(props: MarkdownProps) {
4777
const resolved = createMemo(() => {
4878
const part = props.part
4979
const rawText = typeof part.text === "string" ? part.text : ""
50-
const text = decodeHtmlEntities(rawText)
80+
const text = decodeHtmlEntitiesLocally(rawText)
5181
const themeKey = Boolean(props.isDark) ? "dark" : "light"
5282
const highlightEnabled = !props.disableHighlight
5383
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
5484
if (!partId) {
5585
throw new Error("Markdown rendering requires a part id")
5686
}
5787
const version = resolvePartVersion(part, text)
58-
return { part, text, themeKey, highlightEnabled, partId, version }
88+
const requestKey = `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
89+
return { part, text, themeKey, highlightEnabled, partId, version, requestKey }
5990
})
6091

6192
const cacheHandle = useGlobalCache({
@@ -69,20 +100,40 @@ export function Markdown(props: MarkdownProps) {
69100
version: () => resolved().version,
70101
})
71102

72-
createEffect(async () => {
73-
const { part, text, themeKey, highlightEnabled, version } = resolved()
103+
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
104+
const cacheEntry: RenderCache = {
105+
text: snapshot.text,
106+
html: renderedHtml,
107+
theme: snapshot.themeKey,
108+
mode: snapshot.version,
109+
}
110+
setHtml(renderedHtml)
111+
cacheHandle.set(cacheEntry)
112+
notifyRendered()
113+
}
114+
115+
const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => {
116+
const markdown = await loadMarkdownModule()
117+
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
118+
const rendered = await markdown.renderMarkdown(snapshot.text, {
119+
suppressHighlight: !snapshot.highlightEnabled,
120+
})
74121

75-
// Ensure the markdown highlighter theme matches the active UI theme.
76-
setMarkdownTheme(themeKey === "dark")
122+
if (latestRequestKey === snapshot.requestKey) {
123+
commitCacheEntry(snapshot, rendered)
124+
}
125+
}
77126

78-
latestRequestedText = text
127+
createEffect(() => {
128+
const snapshot = resolved()
129+
latestRequestKey = snapshot.requestKey
79130

80131
const cacheMatches = (cache: RenderCache | undefined) => {
81132
if (!cache) return false
82-
return cache.theme === themeKey && cache.mode === version
133+
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
83134
}
84135

85-
const localCache = part.renderCache
136+
const localCache = snapshot.part.renderCache
86137
if (localCache && cacheMatches(localCache)) {
87138
setHtml(localCache.html)
88139
notifyRendered()
@@ -96,111 +147,82 @@ export function Markdown(props: MarkdownProps) {
96147
return
97148
}
98149

99-
const commitCacheEntry = (renderedHtml: string) => {
100-
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
101-
setHtml(renderedHtml)
102-
cacheHandle.set(cacheEntry)
103-
notifyRendered()
104-
}
105-
106-
if (!highlightEnabled) {
107-
try {
108-
const rendered = await renderMarkdown(text, { suppressHighlight: true })
150+
setHtml(renderFallbackHtml(snapshot.text))
151+
notifyRendered()
109152

110-
if (latestRequestedText === text) {
111-
commitCacheEntry(rendered)
112-
}
113-
} catch (error) {
114-
log.error("Failed to render markdown:", error)
115-
if (latestRequestedText === text) {
116-
commitCacheEntry(text)
117-
}
118-
}
119-
return
120-
}
121-
122-
try {
123-
const rendered = await renderMarkdown(text)
124-
if (latestRequestedText === text) {
125-
commitCacheEntry(rendered)
126-
}
127-
} catch (error) {
153+
void renderSnapshot(snapshot).catch((error) => {
128154
log.error("Failed to render markdown:", error)
129-
if (latestRequestedText === text) {
130-
commitCacheEntry(text)
155+
if (latestRequestKey === snapshot.requestKey) {
156+
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
131157
}
132-
}
158+
})
133159
})
134160

135161
onMount(() => {
136-
const handleClick = async (e: Event) => {
137-
const target = e.target as HTMLElement
162+
const handleClick = async (event: Event) => {
163+
const target = event.target as HTMLElement
138164
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
139165

140-
if (copyButton) {
141-
e.preventDefault()
142-
const code = copyButton.getAttribute("data-code")
143-
if (code) {
144-
const decodedCode = decodeURIComponent(code)
145-
const success = await copyToClipboard(decodedCode)
146-
const copyText = copyButton.querySelector(".copy-text")
147-
if (copyText) {
148-
if (success) {
149-
copyText.textContent = t("markdown.codeBlock.copy.copied")
150-
setTimeout(() => {
151-
copyText.textContent = t("markdown.codeBlock.copy.label")
152-
}, 2000)
153-
} else {
154-
copyText.textContent = t("markdown.codeBlock.copy.failed")
155-
setTimeout(() => {
156-
copyText.textContent = t("markdown.codeBlock.copy.label")
157-
}, 2000)
158-
}
159-
}
160-
}
166+
if (!copyButton) {
167+
return
161168
}
162-
}
163-
164-
containerRef?.addEventListener("click", handleClick)
165169

166-
const cleanupLanguageListener = onLanguagesLoaded(async () => {
167-
if (props.disableHighlight) {
170+
event.preventDefault()
171+
const code = copyButton.getAttribute("data-code")
172+
if (!code) {
168173
return
169174
}
170175

171-
const { part, text, themeKey, version } = resolved()
172-
173-
setMarkdownTheme(themeKey === "dark")
174-
175-
if (latestRequestedText !== text) {
176+
const decodedCode = decodeURIComponent(code)
177+
const success = await copyToClipboard(decodedCode)
178+
const copyText = copyButton.querySelector(".copy-text")
179+
if (!copyText) {
176180
return
177181
}
178182

179-
try {
180-
const rendered = await renderMarkdown(text)
181-
if (latestRequestedText === text) {
182-
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
183-
setHtml(rendered)
184-
cacheHandle.set(cacheEntry)
185-
notifyRendered()
183+
copyText.textContent = success ? t("markdown.codeBlock.copy.copied") : t("markdown.codeBlock.copy.failed")
184+
setTimeout(() => {
185+
copyText.textContent = t("markdown.codeBlock.copy.label")
186+
}, 2000)
187+
}
188+
189+
containerRef?.addEventListener("click", handleClick)
190+
191+
let disposed = false
192+
void loadMarkdownModule()
193+
.then((markdown) => {
194+
if (disposed) {
195+
return
186196
}
187-
} catch (error) {
188-
log.error("Failed to re-render markdown after language load:", error)
189-
}
190-
})
197+
198+
cleanupLanguageListener = markdown.onLanguagesLoaded(() => {
199+
const snapshot = resolved()
200+
if (!snapshot.highlightEnabled) {
201+
return
202+
}
203+
204+
latestRequestKey = snapshot.requestKey
205+
void renderSnapshot(snapshot).catch((error) => {
206+
log.error("Failed to re-render markdown after language load:", error)
207+
})
208+
})
209+
})
210+
.catch((error) => {
211+
log.error("Failed to load markdown module:", error)
212+
})
191213

192214
onCleanup(() => {
215+
disposed = true
193216
containerRef?.removeEventListener("click", handleClick)
194-
cleanupLanguageListener()
217+
cleanupLanguageListener?.()
218+
cleanupLanguageListener = undefined
195219
})
196220
})
197221

198-
const proseClass = () => "markdown-body"
199-
200222
return (
201223
<div
202224
ref={containerRef}
203-
class={proseClass()}
225+
class="markdown-body"
204226
data-view="markdown"
205227
data-part-id={resolved().partId}
206228
data-markdown-theme={resolved().themeKey}

packages/ui/src/lib/git-diff-lowlight.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Root, RootContent } from "hast"
12
import { createLowlight, common } from "lowlight"
23

34
// NOTE:
@@ -7,7 +8,7 @@ import { createLowlight, common } from "lowlight"
78
// may fall back to auto-detection/plain highlighting; add targeted registrations here
89
// if real-world usage shows important gaps.
910

10-
type AstNode = {
11+
type AstNode = RootContent & {
1112
type: string
1213
value?: string
1314
children?: AstNode[]
@@ -30,7 +31,9 @@ type SyntaxFileLine = {
3031

3132
type LowlightApi = ReturnType<typeof createLowlight>
3233

33-
export function processAST(ast: { children: AstNode[] }) {
34+
type AstRoot = Root & { children: AstNode[] }
35+
36+
export function processAST(ast: AstRoot) {
3437
let lineNumber = 1
3538
const syntaxObj: Record<number, SyntaxFileLine> = {}
3639

@@ -109,7 +112,13 @@ export function processAST(ast: { children: AstNode[] }) {
109112
return { syntaxFileObject: syntaxObj, syntaxFileLineNumber: lineNumber }
110113
}
111114

115+
let didWarnUnsupportedAst = false
116+
112117
export function _getAST() {
118+
if (import.meta.env.DEV && !didWarnUnsupportedAst) {
119+
didWarnUnsupportedAst = true
120+
console.warn("[git-diff-lowlight] _getAST is a compatibility stub and does not build a syntax tree")
121+
}
113122
return {}
114123
}
115124

@@ -192,7 +201,7 @@ export const highlighter = {
192201

193202
return lowlight.highlightAuto(raw)
194203
},
195-
processAST(ast: { children: AstNode[] }) {
204+
processAST(ast: AstRoot) {
196205
return processAST(ast)
197206
},
198207
hasRegisteredCurrentLang(lang: string) {

0 commit comments

Comments
 (0)