Browse Source

more performance enhancement

master
Silberengel 1 month ago
parent
commit
f86ab0f41b
  1. 4
      public/healthz.json
  2. 63
      src/lib/components/content/MarkdownRenderer.svelte
  3. 1
      src/lib/components/layout/Header.svelte
  4. 69
      src/lib/modules/feed/FeedPage.svelte
  5. 11
      src/lib/services/cache/event-cache.ts
  6. 18
      src/lib/services/cache/indexeddb-store.ts
  7. 88
      src/lib/services/cache/markdown-cache.ts
  8. 40
      vite.config.ts

4
public/healthz.json

@ -2,7 +2,7 @@
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.2.0", "version": "0.2.0",
"buildTime": "2026-02-07T06:20:23.861Z", "buildTime": "2026-02-07T06:35:08.691Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770445223861 "timestamp": 1770446108691
} }

63
src/lib/components/content/MarkdownRenderer.svelte

@ -13,6 +13,7 @@
import hljs from 'highlight.js'; import hljs from 'highlight.js';
// Use VS Code theme for IDE-like appearance // Use VS Code theme for IDE-like appearance
import 'highlight.js/styles/vs2015.css'; import 'highlight.js/styles/vs2015.css';
import { getCachedMarkdown, cacheMarkdown } from '../../services/cache/markdown-cache.js';
import EmbeddedEvent from './EmbeddedEvent.svelte'; import EmbeddedEvent from './EmbeddedEvent.svelte';
let mountingEmbeddedEvents = $state(false); // Guard for mounting let mountingEmbeddedEvents = $state(false); // Guard for mounting
@ -582,16 +583,34 @@
} }
// Render markdown or AsciiDoc to HTML // Render markdown or AsciiDoc to HTML
function renderMarkdown(text: string): string { async function renderMarkdown(text: string): Promise<string> {
if (!content) return ''; if (!content) return '';
// Check cache first // Ensure content is defined (TypeScript narrowing)
const cached = markdownCache.get(content); const contentToRender: string = content;
// Check IndexedDB cache first (persistent)
const cachedFromDB = await getCachedMarkdown(contentToRender);
if (cachedFromDB) {
// Also update in-memory cache for faster subsequent access
if (markdownCache.size >= MAX_CACHE_SIZE) {
// Remove oldest entry (simple FIFO)
const firstKey = markdownCache.keys().next().value;
if (firstKey !== undefined) {
markdownCache.delete(firstKey);
}
}
markdownCache.set(contentToRender, cachedFromDB);
return cachedFromDB;
}
// Check in-memory cache (faster for same session)
const cached = markdownCache.get(contentToRender);
if (cached !== undefined) { if (cached !== undefined) {
return cached; return cached;
} }
const processed = processContent(content); const processed = processContent(contentToRender);
let html: string; let html: string;
@ -752,12 +771,44 @@
markdownCache.delete(firstKey); markdownCache.delete(firstKey);
} }
} }
markdownCache.set(content, sanitized); markdownCache.set(contentToRender, sanitized);
// Cache in IndexedDB asynchronously (don't await to avoid blocking)
cacheMarkdown(contentToRender, sanitized).catch(err => {
console.debug('Failed to cache markdown in IndexedDB:', err);
});
return sanitized; return sanitized;
} }
const renderedHtml = $derived(renderMarkdown(content)); // Rendered HTML state (async rendering with cache)
let renderedHtml = $state<string>('');
// Render markdown when content changes (with async cache support)
$effect(() => {
if (!content) {
renderedHtml = '';
return;
}
// Start with in-memory cache for instant display
const cached = markdownCache.get(content);
if (cached) {
renderedHtml = cached;
}
// Then check IndexedDB and re-render if needed (only if content is defined)
if (content) {
renderMarkdown(content).then(html => {
if (html !== renderedHtml) {
renderedHtml = html;
}
}).catch(err => {
console.error('Error rendering markdown:', err);
renderedHtml = content || ''; // Fallback to raw content
});
}
});
// Mount ProfileBadge components after rendering // Mount ProfileBadge components after rendering
function mountProfileBadges() { function mountProfileBadges() {

1
src/lib/components/layout/Header.svelte

@ -41,6 +41,7 @@
class="w-full h-full object-cover object-center opacity-90 dark:opacity-70" class="w-full h-full object-cover object-center opacity-90 dark:opacity-70"
style="object-position: center;" style="object-position: center;"
loading="eager" loading="eager"
fetchpriority="high"
/> />
<!-- Overlay gradient for text readability --> <!-- Overlay gradient for text readability -->
<div class="absolute inset-0 bg-gradient-to-b from-fog-bg/30 to-fog-bg/80 dark:from-fog-dark-bg/40 dark:to-fog-dark-bg/90 pointer-events-none"></div> <div class="absolute inset-0 bg-gradient-to-b from-fog-bg/30 to-fog-bg/80 dark:from-fog-dark-bg/40 dark:to-fog-dark-bg/90 pointer-events-none"></div>

69
src/lib/modules/feed/FeedPage.svelte

@ -43,6 +43,28 @@
// Preloaded referenced events (e, a, q tags) - eventId -> referenced event // Preloaded referenced events (e, a, q tags) - eventId -> referenced event
let preloadedReferencedEvents = $state<Map<string, NostrEvent>>(new Map()); let preloadedReferencedEvents = $state<Map<string, NostrEvent>>(new Map());
// Virtual scrolling for performance
let Virtualizer: any = $state(null);
let virtualizerLoading = $state(false);
let virtualizerContainer = $state<HTMLElement | null>(null);
async function loadVirtualizer() {
if (Virtualizer) return Virtualizer;
if (virtualizerLoading) return null;
virtualizerLoading = true;
try {
const module = await import('@tanstack/svelte-virtual');
Virtualizer = module.Virtualizer;
return Virtualizer;
} catch (error) {
console.error('Failed to load virtual scrolling:', error);
return null;
} finally {
virtualizerLoading = false;
}
}
// Filtered events based on filterResult // Filtered events based on filterResult
let filteredEvents = $derived.by(() => { let filteredEvents = $derived.by(() => {
if (!filterResult.value) { if (!filterResult.value) {
@ -454,6 +476,9 @@
isMounted = true; isMounted = true;
loadInProgress = false; loadInProgress = false;
// Load virtualizer for better performance with large feeds
loadVirtualizer();
// Use a small delay to ensure previous page cleanup completes // Use a small delay to ensure previous page cleanup completes
const initTimeout = setTimeout(() => { const initTimeout = setTimeout(() => {
if (!isMounted) return; if (!isMounted) return;
@ -472,7 +497,8 @@
// Register j/k navigation shortcuts // Register j/k navigation shortcuts
if (browser) { if (browser) {
const unregisterJ = keyboardShortcuts.register('j', (e) => { const unregisterJ = keyboardShortcuts.register('j', (e) => {
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA' || document.activeElement?.isContentEditable) { const activeElement = document.activeElement as HTMLElement | null;
if (activeElement?.tagName === 'INPUT' || activeElement?.tagName === 'TEXTAREA' || activeElement?.isContentEditable) {
return; // Don't interfere with typing return; // Don't interfere with typing
} }
e.preventDefault(); e.preventDefault();
@ -481,7 +507,8 @@
}); });
const unregisterK = keyboardShortcuts.register('k', (e) => { const unregisterK = keyboardShortcuts.register('k', (e) => {
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA' || document.activeElement?.isContentEditable) { const activeElement = document.activeElement as HTMLElement | null;
if (activeElement?.tagName === 'INPUT' || activeElement?.tagName === 'TEXTAREA' || activeElement?.isContentEditable) {
return; // Don't interfere with typing return; // Don't interfere with typing
} }
e.preventDefault(); e.preventDefault();
@ -555,12 +582,34 @@
<p class="text-fog-text dark:text-fog-dark-text">No posts found.</p> <p class="text-fog-text dark:text-fog-dark-text">No posts found.</p>
</div> </div>
{:else} {:else}
<div class="feed-posts"> {#if Virtualizer && events.length > 50}
{#each events as event (event.id)} <!-- Use virtual scrolling for large feeds (50+ items) -->
{@const referencedEvent = getReferencedEventForPost(event)} {@const V = Virtualizer}
<FeedPost post={event} preloadedReferencedEvent={referencedEvent} /> <div bind:this={virtualizerContainer} class="feed-posts virtual-scroll-container" style="height: 80vh; overflow: auto;">
{/each} <V
</div> count={events.length}
getScrollElement={() => virtualizerContainer}
estimateSize={() => 300}
overscan={5}
>
{#each Array(events.length) as _, i}
{@const event = events[i]}
{@const referencedEvent = getReferencedEventForPost(event)}
<div data-post-id={event.id}>
<FeedPost post={event} preloadedReferencedEvent={referencedEvent} />
</div>
{/each}
</V>
</div>
{:else}
<!-- Fallback to regular rendering for small feeds or when virtualizer not loaded -->
<div class="feed-posts">
{#each events as event (event.id)}
{@const referencedEvent = getReferencedEventForPost(event)}
<FeedPost post={event} preloadedReferencedEvent={referencedEvent} />
{/each}
</div>
{/if}
<div class="load-more-section"> <div class="load-more-section">
<button <button
@ -601,6 +650,10 @@
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
.virtual-scroll-container {
position: relative;
}
.relay-info { .relay-info {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;

11
src/lib/services/cache/event-cache.ts vendored

@ -58,10 +58,8 @@ export async function cacheEvents(events: NostrEvent[]): Promise<void> {
cached_at: Date.now() cached_at: Date.now()
})); }));
// Put all events in a single batch // Put all events in a single batch using Promise.all for better performance
for (const cached of cachedEvents) { await Promise.all(cachedEvents.map(cached => tx.store.put(cached)));
tx.store.put(cached);
}
// Wait for transaction to complete // Wait for transaction to complete
await tx.done; await tx.done;
@ -146,9 +144,8 @@ export async function deleteEvent(id: string): Promise<void> {
export async function deleteEvents(ids: string[]): Promise<void> { export async function deleteEvents(ids: string[]): Promise<void> {
const db = await getDB(); const db = await getDB();
const tx = db.transaction('events', 'readwrite'); const tx = db.transaction('events', 'readwrite');
for (const id of ids) { // Use Promise.all for batch deletion
await tx.store.delete(id); await Promise.all(ids.map(id => tx.store.delete(id)));
}
await tx.done; await tx.done;
} }

18
src/lib/services/cache/indexeddb-store.ts vendored

@ -5,7 +5,7 @@
import { openDB, type IDBPDatabase } from 'idb'; import { openDB, type IDBPDatabase } from 'idb';
const DB_NAME = 'aitherboard'; const DB_NAME = 'aitherboard';
const DB_VERSION = 7; // Version 6: Removed opengraph store. Version 7: Added RSS cache store const DB_VERSION = 8; // Version 7: Added RSS cache store. Version 8: Added markdown cache store
export interface DatabaseSchema { export interface DatabaseSchema {
events: { events: {
@ -38,6 +38,11 @@ export interface DatabaseSchema {
value: unknown; value: unknown;
indexes: { cached_at: number }; indexes: { cached_at: number };
}; };
markdown: {
key: string; // content hash
value: unknown;
indexes: { cached_at: number };
};
} }
let dbInstance: IDBPDatabase<DatabaseSchema> | null = null; let dbInstance: IDBPDatabase<DatabaseSchema> | null = null;
@ -94,6 +99,12 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
const rssStore = db.createObjectStore('rss', { keyPath: 'feedUrl' }); const rssStore = db.createObjectStore('rss', { keyPath: 'feedUrl' });
rssStore.createIndex('cached_at', 'cached_at', { unique: false }); rssStore.createIndex('cached_at', 'cached_at', { unique: false });
} }
// Markdown cache store
if (!db.objectStoreNames.contains('markdown')) {
const markdownStore = db.createObjectStore('markdown', { keyPath: 'hash' });
markdownStore.createIndex('cached_at', 'cached_at', { unique: false });
}
}, },
blocked() { blocked() {
console.warn('IndexedDB is blocked - another tab may have it open'); console.warn('IndexedDB is blocked - another tab may have it open');
@ -114,7 +125,8 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
!dbInstance.objectStoreNames.contains('search') || !dbInstance.objectStoreNames.contains('search') ||
!dbInstance.objectStoreNames.contains('preferences') || !dbInstance.objectStoreNames.contains('preferences') ||
!dbInstance.objectStoreNames.contains('drafts') || !dbInstance.objectStoreNames.contains('drafts') ||
!dbInstance.objectStoreNames.contains('rss')) { !dbInstance.objectStoreNames.contains('rss') ||
!dbInstance.objectStoreNames.contains('markdown')) {
// Database is corrupted - close and delete it, then recreate // Database is corrupted - close and delete it, then recreate
console.warn('Database missing required stores, recreating...'); console.warn('Database missing required stores, recreating...');
dbInstance.close(); dbInstance.close();
@ -148,6 +160,8 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
db.createObjectStore('drafts', { keyPath: 'id' }); db.createObjectStore('drafts', { keyPath: 'id' });
const rssStore = db.createObjectStore('rss', { keyPath: 'feedUrl' }); const rssStore = db.createObjectStore('rss', { keyPath: 'feedUrl' });
rssStore.createIndex('cached_at', 'cached_at', { unique: false }); rssStore.createIndex('cached_at', 'cached_at', { unique: false });
const markdownStore = db.createObjectStore('markdown', { keyPath: 'hash' });
markdownStore.createIndex('cached_at', 'cached_at', { unique: false });
}, },
blocked() { blocked() {
console.warn('IndexedDB is blocked - another tab may have it open'); console.warn('IndexedDB is blocked - another tab may have it open');

88
src/lib/services/cache/markdown-cache.ts vendored

@ -0,0 +1,88 @@
/**
* Markdown rendering cache with IndexedDB persistence
*/
import { getDB } from './indexeddb-store.js';
export interface CachedMarkdown {
hash: string;
rendered: string;
cached_at: number;
}
/**
* Simple hash function for content (djb2 algorithm)
*/
function hashContent(content: string): string {
let hash = 5381;
for (let i = 0; i < content.length; i++) {
hash = ((hash << 5) + hash) + content.charCodeAt(i);
}
return hash.toString(36);
}
/**
* Get cached rendered markdown
*/
export async function getCachedMarkdown(content: string): Promise<string | null> {
try {
const hash = hashContent(content);
const db = await getDB();
const cached = await db.get('markdown', hash) as CachedMarkdown | undefined;
if (cached) {
// Check if cache is still valid (30 days TTL)
const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
if (Date.now() - cached.cached_at < maxAge) {
return cached.rendered;
}
// Cache expired, delete it
await db.delete('markdown', hash);
}
return null;
} catch (error) {
console.debug('Error getting cached markdown:', error);
return null;
}
}
/**
* Cache rendered markdown
*/
export async function cacheMarkdown(content: string, rendered: string): Promise<void> {
try {
const hash = hashContent(content);
const db = await getDB();
const cached: CachedMarkdown = {
hash,
rendered,
cached_at: Date.now()
};
await db.put('markdown', cached);
} catch (error) {
console.debug('Error caching markdown:', error);
// Don't throw - caching failures shouldn't break the app
}
}
/**
* Clear old markdown cache entries (older than specified timestamp)
*/
export async function clearOldMarkdownCache(olderThan: number): Promise<void> {
try {
const db = await getDB();
const tx = db.transaction('markdown', 'readwrite');
const index = tx.store.index('cached_at');
for await (const cursor of index.iterate()) {
if (cursor.value.cached_at < olderThan) {
await cursor.delete();
}
}
await tx.done;
} catch (error) {
console.debug('Error clearing old markdown cache:', error);
}
}

40
vite.config.ts

@ -18,10 +18,48 @@ export default defineConfig({
options: { options: {
cacheName: 'images-cache', cacheName: 'images-cache',
expiration: { expiration: {
maxEntries: 100, maxEntries: 200,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
} }
} }
},
{
// Cache API responses (relay responses) with network-first strategy
urlPattern: /^wss?:\/\//i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 5 // 5 minutes - short cache for API responses
},
networkTimeoutSeconds: 3 // Fallback to cache if network is slow
}
},
{
// Cache static assets with stale-while-revalidate for better performance
urlPattern: /\.(?:js|css|woff|woff2)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-assets',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 7 // 7 days
}
}
},
{
// Cache HTML pages with network-first for freshness
urlPattern: /\.html$/i,
handler: 'NetworkFirst',
options: {
cacheName: 'html-cache',
expiration: {
maxEntries: 20,
maxAgeSeconds: 60 * 60 * 24 // 1 day
},
networkTimeoutSeconds: 2
}
} }
] ]
}, },

Loading…
Cancel
Save