Skip to content

Commit 3a329ca

Browse files
committed
Fix performance and battery issues
1 parent 4cecf98 commit 3a329ca

6 files changed

Lines changed: 277 additions & 111 deletions

File tree

PERFORMANCE_AUDIT.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# PaperCache Performance & Efficiency Audit
2+
3+
## 📊 Summary
4+
- **Bundle Size**: 🟡 Warning
5+
- **Battery & Idle Efficiency**: 🔴 Issue
6+
- **Memory**: 🟢 Good
7+
- **Static Configurations**: 🟡 Warning
8+
9+
---
10+
11+
## 📦 Bundle Size
12+
**Status: 🟡 Warning**
13+
14+
Vite's production build produces a single massive chunk:
15+
* `dist/assets/index.js` -> **1.83 MB raw** (558.90 KB gzipped)
16+
17+
While Electron loads local files instantly, parsing a monolithic 1.8MB JavaScript file blocks the V8 main thread during the crucial startup phase.
18+
19+
**Top Heavy Dependencies:**
20+
1. `openai` (~9.31 MB unpacked)
21+
2. `mathjs` (~9.00 MB unpacked)
22+
3. `react-dom` (~6.98 MB unpacked)
23+
4. `react-force-graph-2d` (~1.65 MB unpacked)
24+
25+
**Concerns:**
26+
* No code-splitting or lazy loading is currently implemented. The `openai` and `mathjs` libraries are statically imported and loaded into memory on cold boot, even if the user never uses AI or math features in that session.
27+
28+
---
29+
30+
## 🔋 Battery & Idle Efficiency
31+
**Status: 🔴 Issue**
32+
33+
This is the most critical area for a desktop application meant to run in the background.
34+
35+
**Background Timers:**
36+
* **`useReminders.ts`** runs a `setInterval` every 10,000ms (10 seconds) that executes an expensive Regex parse across **every single note** in the user's workspace to check for due dates.
37+
* This timer fires relentlessly in the background, waking the CPU up 6 times a minute even when the window is hidden and the app is idle. This is a severe battery drain pattern.
38+
39+
**Power Throttling:**
40+
* The app does not utilize Electron's `powerMonitor` API. When the laptop suspends or runs on battery saver mode, PaperCache makes no attempt to pause its background checks.
41+
42+
**Reactive `/var` Engine:**
43+
* The global reactive variable and math calculation system evaluates AST trees synchronously. Without a debounce layer, typing rapidly in a massive document with many variables could trigger heavy synchronous calculations, stalling the render thread.
44+
45+
---
46+
47+
## 🧠 Memory
48+
**Status: 🟢 Good**
49+
50+
**Listener Leaks & Architecture:**
51+
* Zustand stores are correctly utilizing slice-subscriptions (`useAppStore(state => state.notes)`), preventing massive re-renders across the React tree.
52+
* `contextIsolation: true` and `nodeIntegration: false` are perfectly configured in the `BrowserWindow` preferences.
53+
* IPC Event listeners (`ipcMain.on`) are mapped cleanly without duplicating listeners across re-renders.
54+
55+
**Object Retention:**
56+
* The `/ctx` AI command slices and retains strings up to 50,000 characters. While handled well, rapid succession of AI context requests could temporarily spike memory before V8's Garbage Collector catches up.
57+
* CodeMirror efficiently virtualizes DOM rendering, meaning large files don't leak DOM nodes.
58+
59+
---
60+
61+
## ⚙️ Static Configurations
62+
**Status: 🟡 Warning**
63+
64+
**Linting:**
65+
* `npm run lint` yields 30 warnings. Most are harmless (`@typescript-eslint/no-explicit-any`, `no-empty`).
66+
* However, a `no-console` warning is present in `useReminders.ts`, which could leak data to the production console stream.
67+
68+
**Electron-Builder:**
69+
* `asar` packaging is implicitly enabled (default), which is excellent.
70+
* `compression: "maximum"` is not defined in `package.json`. Setting this would drastically reduce the distribution payload size (`.dmg`, `.zip`, `.exe`) for end users.
71+
72+
---
73+
74+
## 📋 Recommendations
75+
76+
### High Priority
77+
1. **Refactor `useReminders.ts`**: Replace the 10-second polling interval. Instead, calculate the exact milliseconds until the *next* earliest reminder, and set a single `setTimeout` to fire exactly at that moment.
78+
2. **Implement `powerMonitor`**: Listen for `suspend` and `resume` events from Electron's `powerMonitor` to cleanly pause and resume the reminder polling.
79+
80+
### Medium Priority
81+
3. **Lazy Load Heavy Modules**: Use `import()` to lazily load the `openai` SDK and `mathjs` engine. They should only be fetched and parsed the first time the user actually types `/ai` or an equation.
82+
4. **Debounce Math Calculations**: Add a 300ms debounce to the CodeMirror plugins that trigger the AST variable and math calculations to prevent UI stutter while typing.
83+
84+
### Low Priority
85+
5. **Optimize `electron-builder`**: Add `"compression": "maximum"` to `build` config in `package.json`.
86+
6. **Resolve ESLint Warnings**: Clear out the explicit `any` types across the codebase to ensure robust type safety during future expansions.

electron/main.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
shell,
1212
dialog,
1313
safeStorage,
14+
powerMonitor,
1415
} from 'electron'
1516
import path from 'node:path'
1617
import { fileURLToPath } from 'node:url'
@@ -347,6 +348,14 @@ app.on('web-contents-created', (event, contents) => {
347348
app.whenReady().then(() => {
348349
createWindow()
349350

351+
powerMonitor.on('suspend', () => {
352+
if (win) win.webContents.send('power:suspend')
353+
})
354+
355+
powerMonitor.on('resume', () => {
356+
if (win) win.webContents.send('power:resume')
357+
})
358+
350359
// Setup Tray
351360
tray = new Tray(nativeImage.createEmpty()) // empty initially
352361

electron/preload.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
2727
},
2828
safeStorageEncrypt: (val: string) => ipcRenderer.invoke('safe-storage-encrypt', val),
2929
safeStorageDecrypt: (val: string) => ipcRenderer.invoke('safe-storage-decrypt', val),
30+
onPowerSuspend: (callback: () => void) => {
31+
ipcRenderer.on('power:suspend', () => callback())
32+
},
33+
onPowerResume: (callback: () => void) => {
34+
ipcRenderer.on('power:resume', () => callback())
35+
},
3036
})

src/App.tsx

Lines changed: 77 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { syntaxHighlighting } from '@codemirror/language'
77
import { search } from '@codemirror/search'
88
import { insertTab, indentLess } from '@codemirror/commands'
99
import * as mathjs from 'mathjs'
10-
import OpenAI from 'openai'
1110

1211
import './App.css'
1312
import { getFolderColor } from './utils'
@@ -388,85 +387,89 @@ function App() {
388387

389388
const thinkingText = '\n\u200B...\u200C\n'
390389
view.dispatch({ changes: { from: line.to, insert: thinkingText } })
390+
;(async () => {
391+
try {
392+
let finalBaseUrl = apiBaseUrl.trim()
393+
if (finalBaseUrl.endsWith('/chat/completions')) {
394+
finalBaseUrl = finalBaseUrl.replace('/chat/completions', '')
395+
}
396+
if (finalBaseUrl.endsWith('/')) {
397+
finalBaseUrl = finalBaseUrl.slice(0, -1)
398+
}
391399

392-
try {
393-
let finalBaseUrl = apiBaseUrl.trim()
394-
if (finalBaseUrl.endsWith('/chat/completions')) {
395-
finalBaseUrl = finalBaseUrl.replace('/chat/completions', '')
396-
}
397-
if (finalBaseUrl.endsWith('/')) {
398-
finalBaseUrl = finalBaseUrl.slice(0, -1)
399-
}
400-
401-
const openai = new OpenAI({
402-
apiKey: apiKey.trim() || 'dummy',
403-
baseURL: finalBaseUrl || undefined,
404-
dangerouslyAllowBrowser: true,
405-
defaultHeaders: {
406-
'HTTP-Referer': 'https://github.com/papercache/papercache',
407-
'X-Title': 'PaperCache',
408-
},
409-
})
410-
411-
const systemContent = aiSystemPrompt.trim()
412-
const messages: any[] = []
413-
if (systemContent) {
414-
messages.push({ role: 'system', content: systemContent })
415-
}
400+
const OpenAI = (await import('openai')).default
401+
const openai = new OpenAI({
402+
apiKey: apiKey.trim() || 'dummy',
403+
baseURL: finalBaseUrl || undefined,
404+
dangerouslyAllowBrowser: true,
405+
defaultHeaders: {
406+
'HTTP-Referer': 'https://github.com/papercache/papercache',
407+
'X-Title': 'PaperCache',
408+
},
409+
})
416410

417-
let finalPrompt = prompt
418-
if (isCtx) {
419-
const fullNoteText = view.state.doc.toString()
420-
const MAX_CONTEXT_LENGTH = 50000
421-
let contextText = fullNoteText
422-
if (contextText.length > MAX_CONTEXT_LENGTH) {
423-
contextText =
424-
contextText.substring(0, MAX_CONTEXT_LENGTH) +
425-
'\n...[Context truncated due to length]'
411+
const systemContent = aiSystemPrompt.trim()
412+
const messages: any[] = []
413+
if (systemContent) {
414+
messages.push({ role: 'system', content: systemContent })
426415
}
427-
finalPrompt = `Context:\n${contextText}\n\nPrompt:\n${prompt}`
428-
}
429416

430-
messages.push({ role: 'user', content: finalPrompt })
431-
432-
openai.chat.completions
433-
.create({
434-
model: apiModel.trim() || 'nvidia/nemotron-3-super-120b-a12b:free',
435-
messages: messages,
436-
})
437-
.then((completion: any) => {
438-
let response: string
439-
if (completion.choices && completion.choices.length > 0) {
440-
response = completion.choices[0].message?.content || ''
441-
} else if (completion.error) {
442-
throw new Error(completion.error.message || 'Unknown API Error')
443-
} else {
444-
throw new Error('Unexpected response format: ' + JSON.stringify(completion))
417+
let finalPrompt = prompt
418+
if (isCtx) {
419+
const fullNoteText = view.state.doc.toString()
420+
const MAX_CONTEXT_LENGTH = 50000
421+
let contextText = fullNoteText
422+
if (contextText.length > MAX_CONTEXT_LENGTH) {
423+
contextText =
424+
contextText.substring(0, MAX_CONTEXT_LENGTH) +
425+
'\n...[Context truncated due to length]'
445426
}
427+
finalPrompt = `Context:\n${contextText}\n\nPrompt:\n${prompt}`
428+
}
446429

447-
const docStr = view.state.doc.toString()
448-
const finalVal = docStr.replace(
449-
'\n\u200B...\u200C\n',
450-
'\n\u200B' + response + '\u200C\n'
451-
)
452-
handleEditorChange(finalVal, {})
453-
})
454-
.catch((error) => {
455-
const docStr = view.state.doc.toString()
456-
const errorVal = docStr.replace(
457-
'\n\u200B...\u200C\n',
458-
'\n\u200BError - ' + error.message + '\u200C\n'
459-
)
460-
handleEditorChange(errorVal, {})
461-
})
462-
} catch (err: any) {
463-
const docStr = view.state.doc.toString()
464-
const errorVal = docStr.replace(
465-
'\n\u200B...\u200C\n',
466-
'\n\u200BSetup Error - ' + err.message + '\u200C\n'
467-
)
468-
handleEditorChange(errorVal, {})
469-
}
430+
messages.push({ role: 'user', content: finalPrompt })
431+
432+
openai.chat.completions
433+
.create({
434+
model: apiModel.trim() || 'nvidia/nemotron-3-super-120b-a12b:free',
435+
messages: messages,
436+
})
437+
.then((completion: any) => {
438+
let response: string
439+
if (completion.choices && completion.choices.length > 0) {
440+
response = completion.choices[0].message?.content || ''
441+
} else if (completion.error) {
442+
throw new Error(completion.error.message || 'Unknown API Error')
443+
} else {
444+
throw new Error(
445+
'Unexpected response format: ' + JSON.stringify(completion)
446+
)
447+
}
448+
449+
const docStr = view.state.doc.toString()
450+
const finalVal = docStr.replace(
451+
'\n\u200B...\u200C\n',
452+
'\n\u200B' + response + '\u200C\n'
453+
)
454+
handleEditorChange(finalVal, {})
455+
})
456+
.catch((error) => {
457+
const docStr = view.state.doc.toString()
458+
const errorVal = docStr.replace(
459+
'\n\u200B...\u200C\n',
460+
'\n\u200BError - ' + error.message + '\u200C\n'
461+
)
462+
handleEditorChange(errorVal, {})
463+
})
464+
} catch (err: any) {
465+
const docStr = view.state.doc.toString()
466+
const errorVal = docStr.replace(
467+
'\n\u200B...\u200C\n',
468+
'\n\u200BSetup Error - ' + err.message + '\u200C\n'
469+
)
470+
handleEditorChange(errorVal, {})
471+
}
472+
})()
470473

471474
return true
472475
}

0 commit comments

Comments
 (0)