fix: improvements
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user