fix: improvements

This commit is contained in:
2026-03-28 22:20:19 +01:00
parent cbd8f1568b
commit 223336c606
10 changed files with 506 additions and 249 deletions

View File

@@ -3,7 +3,7 @@
* with initial graph from recollection storage (or example). Save is explicit via save().
*/
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
@@ -59,5 +59,16 @@ export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGra
}, SAVING_DISPLAY_MS)
}, [recollectionId])
// Auto-save after 5 seconds of inactivity when there are unsaved changes.
// Prevents data loss if the user closes the tab without pressing Ctrl+S.
const AUTO_SAVE_DELAY_MS = 5000
const saveRef = useRef(save)
saveRef.current = save
useEffect(() => {
if (!isDirty || !recollectionId) return
const timer = setTimeout(() => saveRef.current(), AUTO_SAVE_DELAY_MS)
return () => clearTimeout(timer)
}, [isDirty, recollectionId, currentSerialized])
return { ...result, save, saveStatus }
}

View File

@@ -14,6 +14,7 @@
*/
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { useResizeHeight } from '@/hooks/useResizeHeight'
import {
Tree,
NodeApi,
@@ -422,6 +423,7 @@ export function TreeBrowser() {
const { tree, handleTreeChange } = useRecollectionSidebar()
const [searchQuery, setSearchQuery] = useState('')
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
const [treeContainerHeight, treeContainerRef] = useResizeHeight(600)
// Build tree from flat structure
const treeNodes = useTreeNodes(tree)
@@ -533,7 +535,7 @@ export function TreeBrowser() {
idAccessor: 'id' as const,
childrenAccessor: 'children' as const,
width: '100%' as const,
height: 600,
height: Math.max(treeContainerHeight, 100),
rowHeight: 32,
indent: ROW_INDENT_PX,
renderRow: TreeRow,
@@ -545,7 +547,7 @@ export function TreeBrowser() {
renderCursor: LogosDropCursor,
children: LogosTreeNode,
}),
[filteredTree, initialOpenState, handleMove, searchQuery]
[filteredTree, initialOpenState, handleMove, searchQuery, treeContainerHeight]
)
return (
@@ -599,7 +601,7 @@ export function TreeBrowser() {
</div>
{/* Tree */}
<div className="flex-1 min-h-0 overflow-hidden">
<div ref={treeContainerRef} className="flex-1 min-h-0 overflow-hidden">
<Tree ref={treeRef} {...arboristTreeProps} />
</div>
</div>

View File

@@ -9,6 +9,13 @@ import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/g
import { getConfigContent, getConfigType, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/rendering'
import { isReachable, resolveExtendsRef, getTemplateRefs, type NodeLike } from '@/lib/graph/templateRefs'
// Module-level cache for resolved Nunjucks output.
// Keyed by a fingerprint of all inputs (template contents + variable/data values + function bodies).
// Avoids re-running the Nunjucks environment when the same inputs are seen again (e.g. undo/redo,
// multiple rendering nodes sharing the same config, rapid edits cycling back to a prior value).
const MAX_RESOLVE_CACHE = 200
const resolveCache = new Map<string, ResolvedContentResult>()
export async function getResolvedContentForConfig(context: SourceRenderingLogicContext): Promise<ResolvedContentResult> {
const { nodes, edges, sourceNodeId, renderNodeId } = context
const srcId = sourceNodeId
@@ -107,6 +114,24 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
}
}
// Build cache key from all inputs that affect the resolved output.
const allTemplateContents = [...configIdsUsed]
.map((cid) => {
const node = nodes.find((n) => n.id === cid)
return getConfigContent((node?.data ?? undefined) as Record<string, unknown> | undefined)
})
.join('\x00')
const allFunctionBodies = [...functionIdsToRegister]
.map((fid) => {
const node = nodes.find((n) => n.id === fid)
return ((node?.data as Record<string, unknown>)?.body as string) ?? ''
})
.join('\x00')
const cacheKey = `${srcId}\x01${JSON.stringify(nunjucksContext)}\x01${allTemplateContents}\x01${allFunctionBodies}`
const cached = resolveCache.get(cacheKey)
if (cached) return cached
const env = new nunjucks.Environment([configLoader], { autoescape: false })
const formatFilterResult = (r: unknown): string => {
@@ -248,7 +273,13 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
return
}
const resolved = afterNunjucks.replace(/(\r?\n)(\s*\r?\n)+/g, '$1').trim()
resolve({ resolved, outputTypeId })
const result: ResolvedContentResult = { resolved, outputTypeId }
// FIFO eviction when cache is full
if (resolveCache.size >= MAX_RESOLVE_CACHE) {
resolveCache.delete(resolveCache.keys().next().value as string)
}
resolveCache.set(cacheKey, result)
resolve(result)
})
})
}

View File

@@ -56,6 +56,7 @@
*/
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAbstractNode } from '@/lib/graph/abstractNode'
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
@@ -175,8 +176,11 @@ export function useRenderingNodeState(
// Subscribe to store graph so we re-render when any node/edge changes (e.g. ascendant data).
// Context only exposes a ref, so we wouldn't re-render when another node updates otherwise.
const storeNodes = useCanvasStore((s) => s.graph.nodes)
const storeEdges = useCanvasStore((s) => s.graph.edges)
// Single combined subscription (vs two separate) halves listener overhead; useShallow prevents
// re-renders when non-graph slices (ui/path) change.
const { storeNodes, storeEdges } = useCanvasStore(
useShallow((s) => ({ storeNodes: s.graph.nodes, storeEdges: s.graph.edges }))
)
const nodes = storeNodes.length > 0 ? storeNodes : contextNodes
const edges = storeEdges.length > 0 ? storeEdges : contextEdges

View File

@@ -53,6 +53,13 @@ export type ConfigType = {
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
const KROKI_TIMEOUT_MS = 15000
const KROKI_CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
// In-flight deduplication: if the same PlantUML content is already being fetched,
// reuse the existing promise instead of firing a duplicate request.
const krokiInflight = new Map<string, Promise<string>>()
// TTL result cache: avoids re-fetching identical content within the TTL window.
const krokiCache = new Map<string, { result: string; cachedAt: number }>()
const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
@@ -172,36 +179,56 @@ const BUILTIN_CONFIG_TYPES: ConfigType[] = [
language: 'plantuml',
insertBlocks: PLANTUML_INSERT_BLOCKS,
render: async (content: string) => {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), KROKI_TIMEOUT_MS)
try {
const res = await fetch(KROKI_PLANTUML_SVG, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: content,
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!res.ok) {
const err = await res.text()
if (res.status >= 500) {
throw new Error('Diagram service unavailable. Try again later.')
}
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
}
return res.text()
} catch (err: unknown) {
clearTimeout(timeoutId)
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error('Diagram request timed out. The service may be slow or unavailable.')
}
if (err.message.includes('fetch') || err.message.includes('network') || err.message.includes('Failed to fetch')) {
throw new Error('Diagram service unavailable. Check your connection or try again later.')
}
}
throw err
// 1. TTL cache hit
const cached = krokiCache.get(content)
if (cached && Date.now() - cached.cachedAt < KROKI_CACHE_TTL_MS) {
return cached.result
}
// 2. In-flight deduplication: reuse an existing request for the same content
const inflight = krokiInflight.get(content)
if (inflight) return inflight
// 3. New request
const fetchPromise = (async () => {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), KROKI_TIMEOUT_MS)
try {
const res = await fetch(KROKI_PLANTUML_SVG, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: content,
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!res.ok) {
const err = await res.text()
if (res.status >= 500) {
throw new Error('Diagram service unavailable. Try again later.')
}
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
}
const svg = await res.text()
krokiCache.set(content, { result: svg, cachedAt: Date.now() })
return svg
} catch (err: unknown) {
clearTimeout(timeoutId)
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error('Diagram request timed out. The service may be slow or unavailable.')
}
if (err.message.includes('fetch') || err.message.includes('network') || err.message.includes('Failed to fetch')) {
throw new Error('Diagram service unavailable. Check your connection or try again later.')
}
}
throw err
} finally {
krokiInflight.delete(content)
}
})()
krokiInflight.set(content, fetchPromise)
return fetchPromise
},
outputMenuDescriptor: {
submenuLabel: 'Export',

View File

@@ -1,21 +1,25 @@
import React from 'react'
import React, { lazy, Suspense } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { Toaster } from 'sonner'
import { ThemeProvider } from './lib/themeContext'
import { registerBuiltinNodes } from './lib/graph/registerBuiltinNodes'
import { registerBuiltinConfigTypes } from './lib/graph/configTypes'
import { KosmosPage } from './app/kosmos/KosmosPage'
import { RecollectionsPage } from './app/recollections/RecollectionsPage'
import { RecollectionLayout } from './app/recollections/RecollectionLayout'
import { LogosPage } from './app/recollections/logos/LogosPage'
import { FluxRoute } from './app/recollections/flux/FluxRoute'
import { NotFoundPage } from './app/NotFoundPage'
import './lib/prismSetup'
import 'prismjs/themes/prism.css'
import './styles.css'
import '@xyflow/react/dist/style.css'
// Lazy-load heavy route components so their bundles are fetched on first navigation,
// not at initial app load. RecollectionLayout and NotFoundPage stay eager (lightweight).
const KosmosPage = lazy(() => import('./app/kosmos/KosmosPage').then(m => ({ default: m.KosmosPage })))
const RecollectionsPage = lazy(() => import('./app/recollections/RecollectionsPage').then(m => ({ default: m.RecollectionsPage })))
const LogosPage = lazy(() => import('./app/recollections/logos/LogosPage').then(m => ({ default: m.LogosPage })))
const FluxRoute = lazy(() => import('./app/recollections/flux/FluxRoute').then(m => ({ default: m.FluxRoute })))
registerBuiltinConfigTypes()
registerBuiltinNodes()
@@ -23,6 +27,7 @@ createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<BrowserRouter>
<Suspense fallback={<div className="flex items-center justify-center h-screen text-muted-foreground text-sm">Loading</div>}>
<Routes>
<Route path="/" element={<KosmosPage />}>
<Route index element={<Navigate to="/recollections" replace />} />
@@ -37,6 +42,7 @@ createRoot(document.getElementById('root')!).render(
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</BrowserRouter>
<Toaster richColors position="bottom-right" />
</ThemeProvider>