fix: performance issues with code renderer (#911)

This commit is contained in:
diced
2025-10-23 21:51:37 -07:00
parent 317e97e3a6
commit 2402c6f0ef
6 changed files with 106 additions and 46 deletions

View File

@@ -1,6 +1,6 @@
services: services:
postgres: postgres:
image: postgres:15 image: postgres:16
restart: unless-stopped restart: unless-stopped
environment: environment:
- POSTGRES_USER=postgres - POSTGRES_USER=postgres

View File

@@ -78,6 +78,7 @@
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.8.2", "react-router-dom": "^7.8.2",
"react-window": "1.8.11",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sharp": "^0.34.3", "sharp": "^0.34.3",
"swr": "^2.3.6", "swr": "^2.3.6",
@@ -96,6 +97,7 @@
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.2",
"eslint": "^9.34.0", "eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",

32
pnpm-lock.yaml generated
View File

@@ -179,6 +179,9 @@ importers:
react-router-dom: react-router-dom:
specifier: ^7.8.2 specifier: ^7.8.2
version: 7.8.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1) version: 7.8.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react-window:
specifier: 1.8.11
version: 1.8.11(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
remark-gfm: remark-gfm:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
@@ -228,6 +231,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^19.1.9 specifier: ^19.1.9
version: 19.1.9(@types/react@19.1.12) version: 19.1.9(@types/react@19.1.12)
'@types/react-window':
specifier: ^1.8.8
version: 1.8.8
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^5.0.2 specifier: ^5.0.2
version: 5.0.2(vite@7.1.4(@types/node@24.3.0)(jiti@2.5.1)(sass@1.92.0)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.20.5)) version: 5.0.2(vite@7.1.4(@types/node@24.3.0)(jiti@2.5.1)(sass@1.92.0)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.20.5))
@@ -2058,6 +2064,9 @@ packages:
peerDependencies: peerDependencies:
'@types/react': ^19.0.0 '@types/react': ^19.0.0
'@types/react-window@1.8.8':
resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==}
'@types/react@19.1.12': '@types/react@19.1.12':
resolution: {integrity: sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==} resolution: {integrity: sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==}
@@ -3552,6 +3561,9 @@ packages:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
merge2@1.4.1: merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -4183,6 +4195,13 @@ packages:
react: '>=16.6.0' react: '>=16.6.0'
react-dom: '>=16.6.0' react-dom: '>=16.6.0'
react-window@1.8.11:
resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==}
engines: {node: '>8.0.0'}
peerDependencies:
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react@19.1.1: react@19.1.1:
resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -7150,6 +7169,10 @@ snapshots:
dependencies: dependencies:
'@types/react': 19.1.12 '@types/react': 19.1.12
'@types/react-window@1.8.8':
dependencies:
'@types/react': 19.1.12
'@types/react@19.1.12': '@types/react@19.1.12':
dependencies: dependencies:
csstype: 3.1.3 csstype: 3.1.3
@@ -8961,6 +8984,8 @@ snapshots:
media-typer@0.3.0: {} media-typer@0.3.0: {}
memoize-one@5.2.1: {}
merge2@1.4.1: {} merge2@1.4.1: {}
micromark-core-commonmark@2.0.3: micromark-core-commonmark@2.0.3:
@@ -9693,6 +9718,13 @@ snapshots:
react: 19.1.1 react: 19.1.1
react-dom: 19.1.1(react@19.1.1) react-dom: 19.1.1(react@19.1.1)
react-window@1.8.11(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
'@babel/runtime': 7.28.3
memoize-one: 5.2.1
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
react@19.1.1: {} react@19.1.1: {}
read-package-up@11.0.0: read-package-up@11.0.0:

View File

@@ -234,15 +234,15 @@ export default function FileModal({
</Title> </Title>
<Combobox <Combobox
zIndex={90000} zIndex={90000}
withinPortal={false}
store={tagsCombobox} store={tagsCombobox}
onOptionSubmit={handleValueSelect} onOptionSubmit={handleValueSelect}
withinPortal={false}
> >
<Combobox.DropdownTarget> <Combobox.DropdownTarget>
<PillsInput <PillsInput
onBlur={() => triggerSave()} onBlur={() => triggerSave()}
pointer pointer
onClick={() => tagsCombobox.toggleDropdown()} onClick={() => tagsCombobox.openDropdown()}
> >
<Pill.Group> <Pill.Group>
{values.length > 0 ? ( {values.length > 0 ? (
@@ -254,9 +254,14 @@ export default function FileModal({
<Combobox.EventsTarget> <Combobox.EventsTarget>
<PillsInput.Field <PillsInput.Field
type='hidden' type='hidden'
onFocus={() => tagsCombobox.openDropdown()}
onBlur={() => tagsCombobox.closeDropdown()} onBlur={() => tagsCombobox.closeDropdown()}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Backspace') { if (
event.key === 'Backspace' &&
value.length > 0 &&
event.currentTarget.value === ''
) {
event.preventDefault(); event.preventDefault();
handleValueRemove(value[value.length - 1]); handleValueRemove(value[value.length - 1]);
} }
@@ -285,9 +290,7 @@ export default function FileModal({
</Combobox.Option> </Combobox.Option>
)) ))
) : ( ) : (
<Combobox.Option value='no-tags' disabled> <Combobox.Empty>No tags found, create one outside of this menu.</Combobox.Empty>
No tags found, create one outside of this menu.
</Combobox.Option>
)} )}
</Combobox.Options> </Combobox.Options>
</Combobox.Dropdown> </Combobox.Dropdown>
@@ -310,8 +313,8 @@ export default function FileModal({
</Button> </Button>
) : ( ) : (
<Combobox <Combobox
store={folderCombobox}
withinPortal={false} withinPortal={false}
store={folderCombobox}
onOptionSubmit={(value) => handleAdd(value)} onOptionSubmit={(value) => handleAdd(value)}
> >
<Combobox.Target> <Combobox.Target>

View File

@@ -27,6 +27,7 @@
.theme { .theme {
color: var(--_color); color: var(--_color);
background: var(--_background); background: var(--_background);
display: block;
.hljs-comment, .hljs-comment,
.hljs-quote { .hljs-quote {

View File

@@ -1,9 +1,10 @@
import { ActionIcon, Button, CopyButton, Paper, ScrollArea, Text, useMantineTheme } from '@mantine/core'; import { ActionIcon, Button, CopyButton, Paper, ScrollArea, Text, useMantineTheme } from '@mantine/core';
import { IconCheck, IconClipboardCopy, IconChevronDown, IconChevronUp } from '@tabler/icons-react'; import { IconCheck, IconChevronDown, IconChevronUp, IconClipboardCopy } from '@tabler/icons-react';
import { useEffect, useState } from 'react'; import type { HLJSApi } from 'highlight.js';
import { useEffect, useMemo, useState } from 'react';
import { FixedSizeList as List } from 'react-window';
import './HighlightCode.theme.scss'; import './HighlightCode.theme.scss';
import { type HLJSApi } from 'highlight.js';
export default function HighlightCode({ language, code }: { language: string; code: string }) { export default function HighlightCode({ language, code }: { language: string; code: string }) {
const theme = useMantineTheme(); const theme = useMantineTheme();
@@ -14,15 +15,56 @@ export default function HighlightCode({ language, code }: { language: string; co
import('highlight.js').then((mod) => setHljs(mod.default || mod)); import('highlight.js').then((mod) => setHljs(mod.default || mod));
}, []); }, []);
const lines = code.split('\n'); const lines = useMemo(() => code.split('\n'), [code]);
const lineNumbers = lines.map((_, i) => i + 1); const visible = expanded ? lines.length : Math.min(lines.length, 50);
const displayLines = expanded ? lines : lines.slice(0, 50); const expandable = lines.length > 50;
const displayLineNumbers = expanded ? lineNumbers : lineNumbers.slice(0, 50);
let lang = language; const lang = useMemo(() => {
if (!hljs || !hljs.getLanguage(lang)) { if (!hljs) return 'plaintext';
lang = 'text'; if (hljs.getLanguage(language)) return language;
}
return 'plaintext';
}, [hljs, language]);
const hlLines = useMemo(() => {
if (!hljs) return lines;
return lines.map(
(line) =>
hljs.highlight(line, {
language: lang,
}).value,
);
}, [lines, hljs, lang]);
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div
style={{
...style,
display: 'flex',
alignItems: 'flex-start',
whiteSpace: 'pre',
fontFamily: 'monospace',
fontSize: '0.8rem',
}}
>
<Text
component='span'
c='dimmed'
mr='md'
style={{
userSelect: 'none',
width: 40,
textAlign: 'right',
flexShrink: 0,
}}
>
{index + 1}
</Text>
<code className='theme hljs' style={{ flex: 1 }} dangerouslySetInnerHTML={{ __html: hlLines[index] }} />
</div>
);
return ( return (
<Paper withBorder p='xs' my='md' pos='relative'> <Paper withBorder p='xs' my='md' pos='relative'>
@@ -44,37 +86,17 @@ export default function HighlightCode({ language, code }: { language: string; co
)} )}
</CopyButton> </CopyButton>
<ScrollArea type='auto' dir='ltr' offsetScrollbars={false}> <ScrollArea type='auto' offsetScrollbars={false} style={{ maxHeight: 400 }}>
<pre style={{ margin: 0, whiteSpace: 'pre', overflowX: 'auto' }} className='theme'> <List height={400} width='100%' itemCount={visible} itemSize={20} overscanCount={10}>
<code className='theme'> {Row}
{displayLines.map((line, i) => ( </List>
<div key={i}>
<Text
component='span'
size='sm'
c='dimmed'
mr='md'
style={{ userSelect: 'none', fontFamily: 'monospace' }}
>
{displayLineNumbers[i]}
</Text>
<span
className='line'
dangerouslySetInnerHTML={{
__html: lang === 'none' || !hljs ? line : hljs.highlight(line, { language: lang }).value,
}}
/>
</div>
))}
</code>
</pre>
</ScrollArea> </ScrollArea>
{lines.length > 50 && ( {expandable && (
<Button <Button
variant='outline' variant='light'
size='compact-sm' size='compact-sm'
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded((e) => !e)}
leftSection={expanded ? <IconChevronUp size='1rem' /> : <IconChevronDown size='1rem' />} leftSection={expanded ? <IconChevronUp size='1rem' /> : <IconChevronDown size='1rem' />}
style={{ position: 'absolute', bottom: '0.5rem', right: '0.5rem' }} style={{ position: 'absolute', bottom: '0.5rem', right: '0.5rem' }}
> >