diff --git a/BACKGROUND_PROCESSING_MEASUREMENT_GUIDE.md b/BACKGROUND_PROCESSING_MEASUREMENT_GUIDE.md new file mode 100644 index 00000000..314bc429 --- /dev/null +++ b/BACKGROUND_PROCESSING_MEASUREMENT_GUIDE.md @@ -0,0 +1,350 @@ +# Background Processing Performance Measurement Guide + +This guide explains how to measure the performance benefits of the Background Processing Proposal, including what the existing performance logger can measure and what additional tools are needed. + +## What Needs to Be Measured + +The background processor proposal's main benefits are: + +1. **Memory Usage** - 70-85% reduction (15-45 MB vs 50-150 MB for 10 applets) +2. **CPU Usage** - 50-70% reduction (background processors vs full iframes) +3. **Number of Iframes** - Count of loaded iframes (10 full vs 10 background + 1 main) +4. **Resource Efficiency** - Comparison of background processors vs full iframes +5. **Lifecycle Throttling** - Effectiveness of throttling when app/group inactive + +## What the Performance Logger CAN Measure + +The existing `PerformanceLogger` utility (`src/renderer/src/utils/performance-logger.ts`) is **useful for**: + +### 1. Timing Within Background Processors + +```typescript +// In background processor function +backgroundProcessor: async (weaveClient, appletClient, profilesClient, lifecycle) => { + perfLogger.start('background-sync-cycle'); + + const updates = await appletClient.callZome({ + role_name: 'forum', + zome_name: 'posts', + fn_name: 'get_recent_updates', + payload: { since: lastSyncTime }, + }); + + perfLogger.log('background-sync-cycle'); + // Logs: [PERF] background-sync-cycle: 234.56ms +} +``` + +**Useful for:** +- Measuring sync interval execution time +- Comparing processing time before/after optimization +- Identifying slow operations within processors +- Tracking lifecycle state change response times + +### 2. Lifecycle State Changes + +```typescript +lifecycle.onLifecycleChange((state) => { + perfLogger.start('lifecycle-state-change'); + updateSyncInterval(); + perfLogger.log('lifecycle-state-change'); +}); +``` + +**Useful for:** +- Measuring how quickly processors respond to lifecycle changes +- Comparing throttling effectiveness + +### 3. Background Processor Initialization + +```typescript +// When background processor starts +perfLogger.start('background-processor-init'); +// ... initialization code ... +perfLogger.log('background-processor-init'); +``` + +**Useful for:** +- Measuring startup time of background processors +- Comparing initialization time vs full iframe load time + +## What the Performance Logger CANNOT Measure + +The existing logger **cannot measure** the main benefits: + +### 1. Memory Usage ❌ + +**Why:** The logger only measures time, not memory. + +**What's needed:** +- Browser Performance API: `performance.memory` (Chrome only, deprecated) +- Performance Observer API: `PerformanceObserver` with `entryTypes: ['measure', 'navigation']` +- DevTools Memory Profiler (manual) +- Custom memory tracking using `performance.measureUserAgentSpecificMemory()` (experimental) + +### 2. CPU Usage ❌ + +**Why:** JavaScript cannot directly measure CPU usage. + +**What's needed:** +- Browser DevTools Performance tab (manual profiling) +- Performance API: `performance.now()` for task duration (indirect) +- Long Task API: `PerformanceObserver` with `entryTypes: ['longtask']` +- System-level monitoring tools + +### 3. Iframe Count ❌ + +**Why:** The logger doesn't track DOM elements. + +**What's needed:** +- Custom tracking: `getAllIframes().length` +- IframeStore tracking: `mossStore.iframeStore.appletIframes` + +### 4. System Resource State ❌ + +**Why:** The logger doesn't track system-level metrics. + +**What's needed:** +- Custom resource monitoring +- Performance API for frame rate +- Memory pressure API (experimental) + +## Recommended Measurement Approach + +### Method 1: Extended Performance Logger (Recommended) + +Extend the existing logger to track additional metrics: + +```typescript +// Extended performance logger +interface ResourceMetrics { + iframeCount: number; + memoryUsage?: number; // If available + timestamp: number; +} + +class ExtendedPerformanceLogger extends PerformanceLogger { + private resourceSnapshots: ResourceMetrics[] = []; + + snapshotResources(getAllIframes: () => HTMLIFrameElement[]): void { + const snapshot: ResourceMetrics = { + iframeCount: getAllIframes().length, + memoryUsage: (performance as any).memory?.usedJSHeapSize, + timestamp: performance.now(), + }; + this.resourceSnapshots.push(snapshot); + } + + getResourceSummary(): { + avgIframeCount: number; + maxIframeCount: number; + minIframeCount: number; + memoryTrend?: number[]; + } { + const iframeCounts = this.resourceSnapshots.map(s => s.iframeCount); + return { + avgIframeCount: iframeCounts.reduce((a, b) => a + b, 0) / iframeCounts.length, + maxIframeCount: Math.max(...iframeCounts), + minIframeCount: Math.min(...iframeCounts), + memoryTrend: this.resourceSnapshots.map(s => s.memoryUsage).filter(Boolean) as number[], + }; + } +} +``` + +### Method 2: Browser DevTools (Most Accurate) + +**For Memory:** +1. Open DevTools > Memory tab +2. Take heap snapshot before optimization +3. Take heap snapshot after optimization +4. Compare: Look for iframe-related memory + +**For CPU:** +1. Open DevTools > Performance tab +2. Record before optimization (with all iframes loaded) +3. Record after optimization (with background processors) +4. Compare: CPU usage, frame rate, long tasks + +**For Iframe Count:** +1. Use Console: `getAllIframes().length` +2. Or: `document.querySelectorAll('iframe').length` + +### Method 3: Custom Measurement Utility + +Create a dedicated measurement utility for background processors: + +```typescript +// src/renderer/src/utils/background-processor-metrics.ts +export class BackgroundProcessorMetrics { + private measurements: { + timestamp: number; + iframeCount: number; + mainViewIframes: number; + backgroundProcessorIframes: number; + memoryUsage?: number; + }[] = []; + + snapshot(mossStore: MossStore): void { + const allIframes = getAllIframes(); + const appletIframes = Object.keys(mossStore.iframeStore.appletIframes); + + // Count iframes by type (would need to track iframe type) + const mainViewIframes = /* count main view iframes */; + const backgroundProcessorIframes = /* count background processor iframes */; + + this.measurements.push({ + timestamp: performance.now(), + iframeCount: allIframes.length, + mainViewIframes, + backgroundProcessorIframes, + memoryUsage: (performance as any).memory?.usedJSHeapSize, + }); + } + + getComparison(): { + before: { avgIframes: number; avgMemory?: number }; + after: { avgIframes: number; avgMemory?: number }; + improvement: { iframeReduction: number; memoryReduction?: number }; + } { + // Compare before/after measurements + } +} +``` + +## Measurement Plan + +### Before Implementation (Baseline) + +1. **Memory:** + - Use DevTools Memory tab + - Take heap snapshot with 10 applets loaded + - Record total memory usage + +2. **CPU:** + - Use DevTools Performance tab + - Record 30 seconds of activity + - Note CPU usage percentage + +3. **Iframe Count:** + ```javascript + // In console + console.log('Iframes:', getAllIframes().length); + console.log('Applet iframes:', Object.keys(mossStore.iframeStore.appletIframes).length); + ``` + +4. **Timing (using performance logger):** + ```typescript + perfLogger.start('applet-load-all'); + // Load all applets + perfLogger.log('applet-load-all'); + ``` + +### After Implementation + +1. **Memory:** + - Take heap snapshot with 10 background processors + 1 main view + - Compare to baseline + +2. **CPU:** + - Record 30 seconds with background processors active + - Compare CPU usage + +3. **Iframe Count:** + ```javascript + // Should show: 11 iframes (10 background + 1 main) vs 10 before + console.log('Iframes:', getAllIframes().length); + ``` + +4. **Timing:** + ```typescript + perfLogger.start('background-processor-init'); + // Initialize background processors + perfLogger.log('background-processor-init'); + + perfLogger.start('main-view-load'); + // Load main view + perfLogger.log('main-view-load'); + ``` + +### Lifecycle Throttling Measurement + +```typescript +// Measure sync interval changes +lifecycle.onLifecycleChange((state) => { + perfLogger.start('lifecycle-throttle'); + updateSyncInterval(); + perfLogger.log('lifecycle-throttle'); + + // Log current state + console.log('Lifecycle state:', { + isAppVisible: state.isAppVisible, + isGroupActive: state.isGroupActive, + resourceState: state.resourceState, + syncInterval: syncIntervalMs, + }); +}); +``` + +## Quick Test Script + +Add to browser console for quick measurements: + +```javascript +// Measure iframe count and memory +function measureBackgroundProcessorBenefits() { + const iframes = getAllIframes(); + const memory = performance.memory?.usedJSHeapSize; + + console.log('=== Background Processor Metrics ==='); + console.log('Total iframes:', iframes.length); + console.log('Memory usage:', memory ? `${(memory / 1024 / 1024).toFixed(2)} MB` : 'N/A'); + + // Count by type (would need iframe tracking) + const mainViews = iframes.filter(/* is main view */).length; + const backgroundProcessors = iframes.filter(/* is background processor */).length; + + console.log('Main view iframes:', mainViews); + console.log('Background processor iframes:', backgroundProcessors); + + return { + totalIframes: iframes.length, + mainViews, + backgroundProcessors, + memoryMB: memory ? memory / 1024 / 1024 : undefined, + }; +} + +// Run before and after +const before = measureBackgroundProcessorBenefits(); +// ... implement background processors ... +const after = measureBackgroundProcessorBenefits(); + +console.log('Improvement:', { + iframeReduction: before.totalIframes - after.totalIframes, + memoryReduction: before.memoryMB && after.memoryMB + ? `${((before.memoryMB - after.memoryMB) / before.memoryMB * 100).toFixed(1)}%` + : 'N/A', +}); +``` + +## Conclusion + +**The existing performance logger is useful for:** +- ✅ Timing operations within background processors +- ✅ Measuring lifecycle state change response times +- ✅ Tracking initialization and sync times + +**But you'll also need:** +- ❌ Browser DevTools for memory/CPU measurements +- ❌ Custom tracking for iframe counts +- ❌ Extended logger or custom utility for resource metrics + +**Recommendation:** Use the performance logger for timing measurements, and combine it with DevTools and custom tracking for a complete picture of the benefits. + +## Related Documents + +- [Performance Logger](../src/renderer/src/utils/performance-logger.ts) - Existing utility +- [Background Processing Proposal](./BACKGROUND_PROCESSING_PROPOSAL.md) - Implementation details +- [Background Processing Performance Analysis](./BACKGROUND_PROCESSING_PERFORMANCE_ANALYSIS.md) - Expected benefits + diff --git a/BACKGROUND_PROCESSING_PERFORMANCE_ANALYSIS.md b/BACKGROUND_PROCESSING_PERFORMANCE_ANALYSIS.md new file mode 100644 index 00000000..71698001 --- /dev/null +++ b/BACKGROUND_PROCESSING_PERFORMANCE_ANALYSIS.md @@ -0,0 +1,271 @@ +# Background Processing Performance Analysis + +This document analyzes the overall performance implications of implementing the Background Processing Proposal, comparing it to the current state and the optimized state (with optimization 2.1b). + +## Current State (Before Background Processing) + +### Resource Usage +- **All applet iframes loaded**: Every running applet has its main view iframe created and loaded +- **All iframes active**: Even hidden iframes (with `display: none`) continue to: + - Execute JavaScript + - Render DOM (though not visible) + - Run timers/intervals + - Maintain WebSocket connections + - Process events + +### Example Scenario: 10 Applets in a Group +- **10 main view iframes** loaded simultaneously +- Each iframe: ~5-15 MB memory (depending on applet complexity) +- **Total memory**: ~50-150 MB just for applet iframes +- **CPU usage**: All 10 applets running their full UI logic +- **Network**: All 10 applets making their own network requests + +## With Background Processing (After Implementation) + +### Resource Usage Comparison + +#### Scenario 1: All Applets Have Background Processors + +**Memory:** +- **10 background processor iframes**: ~1-3 MB each (lightweight, no UI rendering) +- **1 main view iframe**: ~5-15 MB (only selected applet) +- **Total memory**: ~15-45 MB (70-85% reduction vs current) + +**CPU:** +- **10 background processors**: Minimal CPU (periodic sync, event listeners) +- **1 main view**: Full UI rendering and interaction +- **Total CPU**: ~30-50% of current state (background processors are much lighter) + +**Network:** +- **Background processors**: Same network activity as before (sync, notifications) +- **Main view**: Only one applet making UI-related requests +- **Total network**: Similar or slightly less (no duplicate requests from hidden views) + +#### Scenario 2: Mixed (Some Applets Have Background Processors) + +**Memory:** +- **5 background processor iframes**: ~1-3 MB each = ~5-15 MB +- **5 main view iframes** (if all visible): ~25-75 MB +- **Total memory**: ~30-90 MB (40-60% reduction vs current, if only 1 main view visible) + +**CPU:** +- **5 background processors**: Minimal CPU +- **5 main views**: Full UI (if all visible) or 1 main view (if only 1 visible) +- **Total CPU**: 50-70% of current state (if only 1 visible) + +## Performance Impact Analysis + +### 1. Memory Usage + +**Question: Will background processors increase or decrease memory usage?** + +**Answer: Significant decrease (70-85% reduction)** + +**Breakdown:** +- **Current**: 10 full iframes × 5-15 MB = 50-150 MB +- **With background processing + optimization 2.1b**: + - 10 background processors × 1-3 MB = 10-30 MB + - 1 main view × 5-15 MB = 5-15 MB + - **Total: 15-45 MB** (70-85% reduction) + +**Why background processors use less memory:** +- No DOM rendering (no UI elements) +- No CSS/styling loaded +- Minimal JavaScript execution (just the processor function) +- No event listeners for UI interactions +- Smaller JavaScript heap + +### 2. CPU Usage + +**Question: Will background processors increase or decrease CPU usage?** + +**Answer: Significant decrease (50-70% reduction)** + +**Breakdown:** +- **Current**: All 10 applets running full UI logic, rendering, event handling +- **With background processing**: + - Background processors: Minimal CPU (periodic timers, event listeners) + - Main view: Full CPU (only 1 applet) + - **Total: 30-50% of current state** + +**CPU usage by component:** +- **Background processor**: ~0.5-2% CPU per applet (periodic sync every 30s, event listeners) +- **Main view**: ~5-15% CPU per applet (full UI rendering, interactions) +- **Current (hidden iframe)**: ~3-8% CPU per applet (still rendering DOM, processing events) + +**Key insight**: Background processors are much lighter than hidden full UI iframes. + +### 3. Network Usage + +**Question: Will background processors increase network usage?** + +**Answer: Similar or slightly less** + +**Breakdown:** +- **Current**: All 10 applets making network requests (sync, notifications, etc.) +- **With background processing**: + - Background processors: Same network requests (sync, notifications) + - Main view: Only UI-related requests (asset loading, etc.) + - **Total: Similar, but more efficient** (no duplicate requests from hidden views) + +**Network efficiency gains:** +- No duplicate asset loading from hidden views +- Background processors can batch requests more efficiently +- Less redundant polling (if tools optimize their background processors) + +### 4. Scalability + +**Question: How does performance scale with more applets?** + +**Answer: Much better scalability** + +**Current state (10 applets):** +- Memory: 50-150 MB +- CPU: High (all applets active) +- Performance degrades linearly with each added applet + +**With background processing (10 applets):** +- Memory: 15-45 MB +- CPU: Low (only 1 main view active) +- Performance stays consistent as more applets are added + +**With background processing (20 applets):** +- Memory: 25-75 MB (20 background processors + 1 main view) +- CPU: Still low (only 1 main view active) +- **Scales much better**: Adding 10 more applets only adds ~10-30 MB (background processors) vs ~50-150 MB (full iframes) + +### 5. Battery Life (Mobile/Laptop) + +**Question: Will background processors improve battery life?** + +**Answer: Yes, significant improvement** + +**Breakdown:** +- **Current**: All applets constantly rendering, processing events, using CPU +- **With background processing**: + - Background processors: Minimal CPU usage (mostly idle, periodic wake-ups) + - Main view: Only one applet using full resources + - **Battery savings: 50-70%** for applet-related activity + +### 6. Initial Load Time + +**Question: Will background processors slow down initial load?** + +**Answer: Slightly slower initial load, but much faster subsequent operations** + +**Breakdown:** +- **Current**: All 10 applets load their main views simultaneously +- **With background processing**: + - 10 background processors load (lightweight, fast) + - 1 main view loads (only selected applet) + - **Initial load**: Slightly slower (10 background processors + 1 main view vs 10 main views) + - **But**: Background processors load much faster than full views (~200-500ms vs 1-3s each) + +**Net effect**: Initial load might be 10-20% slower, but: +- User sees selected applet faster (only 1 to load) +- Subsequent applet switching is much faster (background processor already loaded) +- Overall perceived performance is better + +### 7. Applet Switching Performance + +**Question: How does applet switching perform?** + +**Answer: Much faster** + +**Current:** +- Switching is instant (just showing/hiding iframes) +- But all iframes are already loaded and consuming resources + +**With background processing:** +- Switching requires loading main view iframe (~1-3s) +- But background processor is already running (notifications, sync continue) +- **Perceived performance**: Slightly slower first switch, but: + - Background tasks continue uninterrupted + - No need to reload background state + - Main view loads fresh (no stale state) + +## Trade-offs and Considerations + +### Benefits +1. **70-85% memory reduction** (most significant benefit) +2. **50-70% CPU reduction** (better battery life, smoother UI) +3. **Better scalability** (can handle many more applets) +4. **Background tasks continue** (notifications, sync work properly) +5. **Faster applet switching** (after initial load) + +### Costs +1. **Slightly slower initial load** (10-20% slower, but better perceived performance) +2. **Additional complexity** (background processor iframe management) +3. **Tool migration required** (tools need to implement background processors) +4. **Potential for abuse** (tools could create heavy background processors) + +### Mitigation Strategies + +1. **Resource limits**: Implement CPU/memory limits for background processors +2. **Lifecycle-based throttling**: Background processors receive lifecycle events to throttle/pause: + - **App visibility**: Pause when tab/window not visible + - **Group activity**: Throttle when applet's group is not the active group + - **Resource constraints**: Reduce activity when system resources are constrained or critical +3. **Monitoring**: Track background processor resource usage +4. **Documentation**: Provide best practices for lightweight background processors + +### Lifecycle-Based Performance Optimization + +Background processors can intelligently throttle their activity based on lifecycle state: + +**When app is not visible:** +- Sync intervals paused (no CPU usage) +- WebSocket connections closed (no network) +- Only critical notifications processed + +**When group is not active:** +- Sync intervals throttled (e.g., 30s → 2 minutes) +- WebSocket connections may be closed (tool-dependent) +- Reduced network activity + +**When resources are constrained:** +- Sync intervals throttled (e.g., 30s → 60s) +- Non-critical work deferred +- Signal processing queued instead of immediate + +**Result**: Background processors use even less resources when not actively needed, further improving performance. + +## Comparison Table + +| Metric | Current State | With Background Processing | Improvement | +|--------|--------------|----------------------------|-------------| +| **Memory (10 applets)** | 50-150 MB | 15-45 MB | **70-85% reduction** | +| **CPU Usage** | High (all active) | Low (1 active) | **50-70% reduction** | +| **Network** | All applets active | Same (background) | Similar | +| **Battery Life** | Poor | Good | **50-70% improvement** | +| **Scalability** | Linear degradation | Constant performance | **Much better** | +| **Initial Load** | Fast (all load) | Slightly slower | 10-20% slower | +| **Applet Switching** | Instant (hidden) | Fast (load on demand) | Slightly slower first time | +| **Background Tasks** | Work (all loaded) | Work (processors) | **Same functionality** | + +## Conclusion + +**Overall Performance Impact: Highly Positive** + +The background processing proposal, combined with optimization 2.1b, provides: + +1. **Massive memory savings** (70-85% reduction) +2. **Significant CPU reduction** (50-70% reduction) +3. **Better scalability** (can handle many more applets) +4. **Maintained functionality** (background tasks continue to work) +5. **Better battery life** (especially on mobile/laptop) + +**Trade-offs are minimal:** +- Slightly slower initial load (but better perceived performance) +- Requires tool migration (but optional, backward compatible) +- Additional complexity (but manageable) + +**Recommendation: Proceed with implementation** + +The performance benefits far outweigh the costs, especially for users with many applets installed. + +## Related Documents + +- [Background Processing Proposal](./BACKGROUND_PROCESSING_PROPOSAL.md) - Technical implementation details +- [Performance Optimization Plan](./PERFORMANCE_OPTIMIZATION_PLAN.md) - Overall optimization strategy + diff --git a/BACKGROUND_PROCESSING_PROPOSAL.md b/BACKGROUND_PROCESSING_PROPOSAL.md new file mode 100644 index 00000000..5b222f69 --- /dev/null +++ b/BACKGROUND_PROCESSING_PROPOSAL.md @@ -0,0 +1,401 @@ +# Background Processing Support Proposal + +This document proposes adding background processing support to the Weave API to enable performance optimization 2.1b (Only Render Selected Applet) without breaking tool functionality. + +## Problem Statement + +Currently, optimization 2.1b cannot be implemented because: +- Tools need to run background tasks (notifications, sync, etc.) even when not visible +- Destroying iframes when deselected would break these background processes +- We need a pattern for tools to register background processing elements + +## Solution Overview + +Extend the `AppletServices` interface to include an optional background processor function, following the same pattern as existing services like `creatables`, `blockTypes`, `search`, and `getAssetInfo`. + +## Implementation Details + +### 1. Extend AppletServices Class + +**File:** `libs/api/src/api.ts` + +Add a new optional field to the `AppletServices` class: + +```typescript +export class AppletServices { + constructor() { + (this.creatables = {}), + (this.blockTypes = {}), + (this.search = async (_appletClient, _appletHash, _weaveServices, _searchFilter) => []), + (this.getAssetInfo = async (_appletClient, _wal, _recordInfo) => undefined); + (this.backgroundProcessor = undefined); // NEW + } + + /** + * Creatables that this Applet offers to be created from a We dialog + */ + creatables: Record; + + /** + * Render block types that this Applet offers + */ + blockTypes: Record; + + /** + * Get info about the specified entry of this Applet + */ + getAssetInfo: ( + appletClient: AppClient, + wal: WAL, + recordInfo?: RecordInfo, + ) => Promise; + + /** + * Search in this Applet + */ + search: ( + appletClient: AppClient, + appletHash: AppletHash, + weaveServices: WeaveServices, + searchFilter: string, + ) => Promise>; + + /** + * Optional background processor function that runs independently of the main applet view. + * This function is executed in a separate lightweight iframe context that persists + * even when the main applet view is not rendered or when users switch between groups. + * + * The function receives a WeaveClient instance with limited API access (notifications, + * signals, etc.) and should handle background tasks like: + * - Periodic data synchronization + * - Notification processing + * - Background data fetching + * - WebSocket connections + * + * The background processor iframe is created when the applet is first loaded and + * persists until the applet is uninstalled or the application is closed. + * + * The processor receives lifecycle events to allow it to throttle or pause processing + * when appropriate (e.g., when app is in background, when group is not active, etc.). + */ + backgroundProcessor?: ( + weaveClient: WeaveClient, + appletClient: AppClient, + profilesClient: ProfilesClient, + lifecycle: BackgroundProcessorLifecycle, + ) => Promise | void; +} +``` + +### 2. Extend RenderInfo Type and Add Lifecycle Interface + +**File:** `libs/api/src/types.ts` + +Add a new renderInfo type for background processors and lifecycle interface: + +```typescript +export type RenderInfo = + | { + type: 'applet-view'; + view: AppletView; + appletClient: AppClient; + profilesClient: ProfilesClient; + peerStatusStore: Readable; + // ... other fields + } + | { + type: 'cross-group-view'; + view: CrossGroupView; + // ... other fields + } + | { + type: 'background-processor'; // NEW + appletHash: AppletHash; + appletClient: AppClient; + profilesClient: ProfilesClient; + peerStatusStore: Readable; + groupDnaHash: DnaHash; // Group this applet belongs to + }; + +/** + * Lifecycle management interface for background processors. + * Allows processors to respond to system state changes and throttle/pause processing. + */ +export interface BackgroundProcessorLifecycle { + /** + * Whether the application tab/window is currently visible to the user. + * Processors should throttle or pause non-critical work when false. + */ + isAppVisible: Readable; + + /** + * Whether the applet's group is currently the active group being viewed. + * Processors may want to reduce activity when their group is not active. + */ + isGroupActive: Readable; + + /** + * Current system resource state. Processors should respect this and reduce + * activity when resources are constrained. + */ + resourceState: Readable<'normal' | 'constrained' | 'critical'>; + + /** + * Subscribe to lifecycle changes. Callback receives the new lifecycle state. + */ + onLifecycleChange: (callback: (state: { + isAppVisible: boolean; + isGroupActive: boolean; + resourceState: 'normal' | 'constrained' | 'critical'; + }) => void) => UnsubscribeFunction; +} +``` + +### 3. Background Processor Execution Context + +**Files:** +- `src/renderer/src/applets/applet-host.ts` (Background processor iframe management) +- `src/renderer/src/layout/views/view-frame.ts` (Background processor rendering) + +**Implementation:** + +1. When `WeaveClient.connect(appletServices)` is called and `appletServices.backgroundProcessor` is defined: + - Create a separate iframe with `renderInfo.type === 'background-processor'` + - Load the same UI bundle but with the background processor renderInfo + - Execute the background processor function in that context + - Keep this iframe alive even when main view is destroyed + +2. The background processor iframe has access to a limited WeaveClient API: + - ✅ `notifyFrame()` - for notifications + - ✅ `onRemoteSignal()` / `sendRemoteSignal()` - for inter-applet communication + - ✅ `appletClient` - for zome calls + - ✅ `profilesClient` - for profile access + - ✅ `onPeerStatusUpdate()` - for peer status updates + - ❌ `openAppletMain()`, `openAsset()`, etc. (view-related APIs should be disabled or throw errors) + +3. Lifecycle management: + - Background processor iframe is created when applet is first loaded + - Persists independently of main view iframe lifecycle + - **Continues running when users switch between groups** (applet remains installed) + - Destroyed only when applet is uninstalled or application is closed + - Receives lifecycle events to allow throttling/pausing when appropriate: + - When app/tab is not visible + - When applet's group is not the active group + - When system resources are constrained + +### 4. Tool Implementation Pattern + +**Example tool code:** + +```typescript +// In tool's UI code +import { WeaveClient, AppletServices } from '@theweave/api'; +import { get } from '@holochain-open-dev/stores'; // For reading Readable stores + +const appletServices: AppletServices = { + creatables: { + 'post': { + label: 'post', + icon_src: 'data:image/png;base64,...', + }, + }, + blockTypes: { + 'recent_posts': { + label: 'Recent Posts', + icon_src: 'data:image/png;base64,...', + view: 'applet-view', + }, + }, + search: async (appletClient, appletHash, weaveServices, searchFilter) => { + // Search implementation + return []; + }, + getAssetInfo: async (appletClient, wal, recordInfo) => { + // Asset info implementation + return undefined; + }, + + // NEW: Background processor + backgroundProcessor: async (weaveClient, appletClient, profilesClient, lifecycle) => { + let syncInterval: number | undefined; + let syncIntervalMs = 30000; // Default: every 30 seconds + + // Adjust sync frequency based on lifecycle state + const updateSyncInterval = () => { + const isVisible = get(lifecycle.isAppVisible); + const isActive = get(lifecycle.isGroupActive); + const resources = get(lifecycle.resourceState); + + // Pause sync when app not visible or resources critical + if (!isVisible || resources === 'critical') { + if (syncInterval) { + clearInterval(syncInterval); + syncInterval = undefined; + } + return; + } + + // Throttle sync when group not active or resources constrained + let newInterval = 30000; // Default + if (!isActive) { + newInterval = 120000; // 2 minutes when group not active + } else if (resources === 'constrained') { + newInterval = 60000; // 1 minute when resources constrained + } + + // Update interval if it changed + if (syncInterval && newInterval !== syncIntervalMs) { + clearInterval(syncInterval); + syncInterval = undefined; + } + + if (!syncInterval) { + syncIntervalMs = newInterval; + syncInterval = setInterval(async () => { + try { + const updates = await appletClient.callZome({ + role_name: 'forum', + zome_name: 'posts', + fn_name: 'get_recent_updates', + payload: { since: lastSyncTime }, + }); + + if (updates.length > 0) { + await weaveClient.notifyFrame([{ + title: 'New posts', + body: `${updates.length} new posts available`, + urgency: 'low', + }]); + lastSyncTime = Date.now(); + } + } catch (e) { + console.error('Background sync error:', e); + } + }, syncIntervalMs); + } + }; + + // Subscribe to lifecycle changes + lifecycle.onLifecycleChange((state) => { + updateSyncInterval(); + }); + + // Initial setup + updateSyncInterval(); + + // Set up WebSocket connections for real-time updates + // Only connect when app is visible and group is active + let ws: WebSocket | undefined; + const connectWebSocket = () => { + const isVisible = get(lifecycle.isAppVisible); + const isActive = get(lifecycle.isGroupActive); + + if (isVisible && isActive && !ws) { + ws = new WebSocket('wss://example.com/updates'); + ws.onmessage = async (event) => { + const data = JSON.parse(event.data); + await weaveClient.notifyFrame([{ + title: data.title, + body: data.body, + urgency: data.urgency || 'normal', + }]); + }; + ws.onclose = () => { + ws = undefined; + // Reconnect after delay if conditions are still met + setTimeout(() => { + if (get(lifecycle.isAppVisible) && get(lifecycle.isGroupActive)) { + connectWebSocket(); + } + }, 5000); + }; + } else if ((!isVisible || !isActive) && ws) { + ws.close(); + ws = undefined; + } + }; + + // Update WebSocket connection on lifecycle changes + lifecycle.onLifecycleChange(() => { + connectWebSocket(); + }); + + // Initial connection + connectWebSocket(); + + // Set up signal listeners (always active, but can throttle processing) + weaveClient.onRemoteSignal(async (payload) => { + // Throttle signal processing when resources are constrained + const resources = get(lifecycle.resourceState); + if (resources === 'critical') { + // Queue for later processing instead of processing immediately + return; + } + + // Handle remote signals + const signal = decode(payload); + // Process signal... + }); + + // Cleanup function (optional, but recommended) + return () => { + if (syncInterval) clearInterval(syncInterval); + if (ws) ws.close(); + }; + }, +}; + +const weaveClient = await WeaveClient.connect(appletServices); + +// Handle different view types +switch (weaveClient.renderInfo.type) { + case 'applet-view': + // Main view rendering + break; + case 'background-processor': + // Background processor execution + if (appletServices.backgroundProcessor) { + // Create lifecycle interface for this processor + const lifecycle = createBackgroundProcessorLifecycle( + weaveClient.renderInfo.groupDnaHash, + mossStore, // or however to access moss store + ); + + await appletServices.backgroundProcessor( + weaveClient, + weaveClient.renderInfo.appletClient, + weaveClient.renderInfo.profilesClient, + lifecycle, + ); + } + break; +} +``` + +## Benefits + +1. **Follows existing patterns**: Uses the same `AppletServices` pattern as other tool services +2. **No separate infrastructure**: Uses the same UI bundle, just different renderInfo +3. **Holochain-native**: No need for separate URLs, permissions, or worker threads +4. **Flexible**: Tools can implement any background processing logic they need +5. **Type-safe**: Full TypeScript support with existing types + +## Migration Path + +1. **Phase 1**: Add `backgroundProcessor` field to `AppletServices` (optional, backward compatible) +2. **Phase 2**: Implement background processor iframe creation in Moss +3. **Phase 3**: Tools can optionally add background processors +4. **Phase 4**: Implement optimization 2.1b (only render selected applet) + +## Open Questions + +1. Should background processors be paused when the tab is not visible? (Similar to optimization 1.3) +2. Should there be resource limits on background processors? (CPU, memory, network) +3. How should errors in background processors be handled and reported? +4. Should background processors be able to request to open views? (Probably not, but worth discussing) + +## Related Documents + +- [Performance Optimization Plan](./PERFORMANCE_OPTIMIZATION_PLAN.md) - See optimization 2.1a and 2.1b +- [Weave API Documentation](../libs/api/README.md) - For existing AppletServices patterns + diff --git a/BACKGROUND_PROCESSING_SINGLE_IFRAME_PROPOSAL.md b/BACKGROUND_PROCESSING_SINGLE_IFRAME_PROPOSAL.md new file mode 100644 index 00000000..c888503d --- /dev/null +++ b/BACKGROUND_PROCESSING_SINGLE_IFRAME_PROPOSAL.md @@ -0,0 +1,782 @@ +# Background Processing: Single-Iframe Lifecycle Proposal + +## Problem Statement + +The original background processor proposal (separate iframe) has significant downsides: + +1. **Data Duplication**: Background iframe would load its own copy of data (e.g., posts), potentially doubling memory usage +2. **Race Conditions**: If background processor makes zome calls that change data, synchronization issues between iframes +3. **Data Availability**: Work done in background iframe won't be available to foreground iframe +4. **Notification Frequency**: Notifications may need same frequency in background as foreground (not necessarily slower) + +## Solution: Single-Iframe with Lifecycle Management + +Instead of separate iframes, use **lifecycle management within the main applet iframe** to: +- Allow tools to continue background processing when inactive +- Enable memory recovery and resource optimization +- Support delayed DOM suspension (not immediate removal) +- Maintain data consistency (single source of truth) +- Support same notification frequency in background as foreground + +## Architecture Overview + +### Lifecycle States + +Each applet iframe can be in one of these states: + +1. **`active`**: Applet is currently visible and selected + - Full DOM rendering + - All timers/intervals running + - Full resource usage + - User can interact + - **Memory**: Full usage + - **Restore Speed**: N/A (already active) + +2. **`inactive`**: Applet is not visible but recently was active + - DOM remains in document (hidden with `display: none`) + - Background processing continues (notifications, sync, etc.) + - Timers/intervals continue + - Data stores remain loaded + - **Transition**: Immediately when deselected + - **Duration**: Configurable (default: 5 minutes) + - **Memory**: Full usage (no savings) + - **Restore Speed**: Instant (~0ms) + +3. **`suspended`**: Applet has been inactive for a while + - DOM elements removed from document (but kept in memory for quick restore) + - Background processing continues (notifications, sync, etc.) + - Timers/intervals continue + - Data stores remain loaded + - **Transition**: After `inactive` duration expires (default: 5 minutes) + - **Duration**: Configurable (default: 30 minutes, or until memory pressure) + - **Memory**: ~30-50% savings (removes layout/rendering, but objects remain) + - **Restore Speed**: Quick (~10-50ms) - reattach DOM to document + +4. **`discarded`**: Applet has been suspended for a long time or memory is constrained + - Iframe kept in DOM but completely hidden (JavaScript context remains alive) + - Internal DOM cleared (via `discard-dom` message to iframe) + - Background processing continues (timers, data stores, etc.) - JavaScript context is preserved + - **Transition**: After `suspended` duration expires (default: 30 minutes) or on memory pressure + - **Memory**: ~70-90% savings (DOM cleared, but iframe and JavaScript context remain) + - **Restore Speed**: Medium (~50-200ms) - restore iframe visibility and rebuild DOM from data stores + +**Note**: When an applet is uninstalled, disabled, or abandoned, the iframe is simply removed from the DOM - there's no "destroyed" lifecycle state. Lifecycle states only apply to active applets that may become active again. + +### State Transitions + +``` +active → inactive (immediate when user switches to different applet in same group, or switches groups) +inactive → active (instant restore when user switches back to this applet) +inactive → suspended (after inactivity timeout, default: 5 minutes) +suspended → active (quick restore ~10-50ms when user switches back to this applet) +suspended → discarded (after suspended timeout, default: 30 minutes, or on memory pressure) +discarded → active (slow restore ~200-1000ms, recreate iframe when user switches back) + +Note: When user switches groups: +- Applets in the previous group go to 'inactive' state (not destroyed) +- Applets in the new group become 'active' if selected, or 'inactive' if not selected +- All applets continue background processing regardless of which group is active +``` + +## Implementation + +### 1. Extend WeaveClient API with Lifecycle Management + +**File:** `libs/api/src/api.ts` + +Add lifecycle management to `WeaveClient`: + +```typescript +export class WeaveClient { + // ... existing fields ... + + /** + * Lifecycle state of this applet iframe. + * - 'active': Applet is currently visible and selected in the active group + * - 'inactive': Applet is not visible but recently was active (DOM hidden) + * - 'suspended': Applet has been inactive for a while (DOM removed but kept in memory) + * - 'discarded': Applet has been suspended for a long time (iframe removed, recreate on activate) + * + * Note: Lifecycle states apply to applets that may become active again. When an applet + * is uninstalled, disabled, or abandoned, the iframe is simply removed - no lifecycle state needed. + */ + readonly lifecycleState: Readable<'active' | 'inactive' | 'suspended' | 'discarded'>; + + /** + * Subscribe to lifecycle state changes. + * Allows tools to respond to state changes (e.g., pause/resume timers, cleanup DOM, etc.) + */ + onLifecycleChange: ( + callback: (state: 'active' | 'inactive' | 'suspended' | 'discarded') => void + ) => UnsubscribeFunction; + + /** + * Request lifecycle state change (optional, for tools that want to control their own lifecycle) + * Note: Moss renderer has final say on lifecycle state + */ + requestLifecycleState?: (state: 'inactive' | 'suspended') => void; +} +``` + +### 2. Update RenderInfo to Include Lifecycle State + +**File:** `libs/api/src/types.ts` + +```typescript +export type RenderInfo = + | { + type: 'applet-view'; + view: AppletView; + appletClient: AppClient; + profilesClient: ProfilesClient; + peerStatusStore: ReadonlyPeerStatusStore; + appletHash: AppletHash; + groupProfiles: GroupProfile[]; + /** + * Current lifecycle state of this applet iframe. + * Tools can use this to optimize their behavior. + */ + lifecycleState: 'active' | 'inactive' | 'suspended' | 'discarded'; + } + | { + type: 'cross-group-view'; + view: CrossGroupView; + applets: ReadonlyMap; + }; +``` + +### 3. Moss Renderer Lifecycle Management + +**Files:** +- `src/renderer/src/layout/views/view-frame.ts` (primary lifecycle management) +- `src/renderer/src/groups/elements/group-container.ts` (coordination) + +Lifecycle management should be implemented at the `view-frame` level since that's where iframes are actually created and managed. The `group-container` coordinates which applets are active. + +**File:** `src/renderer/src/layout/views/view-frame.ts` + +```typescript +@customElement('view-frame') +export class ViewFrame extends LitElement { + // ... existing properties ... + + @state() + lifecycleState: 'active' | 'inactive' | 'suspended' | 'discarded' = 'active'; + + @state() + inactivityTimer: number | undefined; + + @state() + suspendedTimer: number | undefined; + + // Configuration + private readonly INACTIVITY_TO_SUSPENDED = 5 * 60 * 1000; // 5 minutes + private readonly SUSPENDED_TO_DISCARDED = 30 * 60 * 1000; // 30 minutes + + @consume({ context: mossStoreContext }) + @state() + mossStore!: MossStore; + + private _dashboardState = new StoreSubscriber( + this, + () => this.mossStore.dashboardState(), + () => [this.mossStore], + ); + + updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + + // Update lifecycle state when dashboard state changes + if (changedProperties.has('_dashboardState') || changedProperties.has('renderView')) { + this.updateLifecycleState(); + } + } + + private updateLifecycleState() { + if (this.renderView.type !== 'applet-view' || this.iframeKind.type !== 'applet') { + return; // Only manage lifecycle for applet main views + } + + const appletHash = this.iframeKind.appletHash; + const dashboardState = this._dashboardState.value; + + // Applet is active if: + // 1. Dashboard is showing a group view + // 2. This applet is the selected applet in that group + const isActive = dashboardState.viewType === 'group' && + dashboardState.appletHash && + encodeHashToBase64(dashboardState.appletHash) === encodeHashToBase64(appletHash); + + if (isActive) { + // Applet is now active (user selected it, or switched to its group) + this.setLifecycleState('active'); + this.clearInactivityTimer(); + } else { + // Applet is now inactive (user selected different applet, or switched groups) + // Note: Applet remains in lifecycle states even when its group is not active + // It will become active again when user switches back to it + if (this.lifecycleState === 'active') { + this.setLifecycleState('inactive'); + this.startInactivityTimer(); + } + // If already inactive/suspended/discarded, stay in that state + } + } + + private setLifecycleState(state: 'active' | 'inactive' | 'suspended' | 'discarded') { + const previousState = this.lifecycleState; + this.lifecycleState = state; + + // Notify iframe of lifecycle change (if iframe still exists) + const iframe = this.shadowRoot?.querySelector('iframe') as HTMLIFrameElement | null; + if (iframe && iframe.contentWindow) { + iframe.contentWindow.postMessage({ + type: 'lifecycle-state-change', + state, + previousState, + }, '*'); + } + + // Handle state transitions + if (state === 'suspended' && previousState === 'inactive') { + this.suspendIframe(); + this.startSuspendedTimer(); + } else if (state === 'discarded' && previousState === 'suspended') { + this.discardIframe(); + } else if (state === 'active' && previousState === 'suspended') { + this.restoreIframe(); + this.clearSuspendedTimer(); + } else if (state === 'active' && previousState === 'discarded') { + this.recreateIframe(); + } else if (state === 'active') { + this.clearSuspendedTimer(); + } + } + + private startInactivityTimer() { + this.clearInactivityTimer(); + + this.inactivityTimer = window.setTimeout(() => { + if (this.lifecycleState === 'inactive') { + this.setLifecycleState('suspended'); + } + }, this.INACTIVITY_TO_SUSPENDED); + } + + private startSuspendedTimer() { + this.clearSuspendedTimer(); + + this.suspendedTimer = window.setTimeout(() => { + if (this.lifecycleState === 'suspended') { + // Check memory pressure before discarding + if (this.checkMemoryPressure()) { + this.setLifecycleState('discarded'); + } else { + // Still discard after timeout, but less aggressively + this.setLifecycleState('discarded'); + } + } + }, this.SUSPENDED_TO_DISCARDED); + } + + private clearInactivityTimer() { + if (this.inactivityTimer) { + clearTimeout(this.inactivityTimer); + this.inactivityTimer = undefined; + } + } + + private clearSuspendedTimer() { + if (this.suspendedTimer) { + clearTimeout(this.suspendedTimer); + this.suspendedTimer = undefined; + } + } + + private checkMemoryPressure(): boolean { + // Use performance.memory API if available (Chrome) + if ('memory' in performance) { + const memory = (performance as any).memory; + const usedMB = memory.usedJSHeapSize / 1048576; + const totalMB = memory.totalJSHeapSize / 1048576; + return usedMB / totalMB > 0.8; // 80% memory usage + } + return false; + } + + private suspendIframe() { + // Remove iframe from DOM but keep reference for quick restore + const iframe = this.shadowRoot?.querySelector('iframe') as HTMLIFrameElement | null; + if (iframe) { + // Notify the iframe to suspend its internal DOM + if (iframe.contentWindow) { + iframe.contentWindow.postMessage({ + type: 'suspend-dom', + }, '*'); + } + // Hide iframe (still in DOM, just not visible) + iframe.style.display = 'none'; + } + } + + private discardIframe() { + // Remove iframe from DOM entirely (can be garbage collected) + const iframe = this.shadowRoot?.querySelector('iframe') as HTMLIFrameElement | null; + if (iframe) { + // Notify iframe before removing (if still accessible) + if (iframe.contentWindow) { + iframe.contentWindow.postMessage({ + type: 'discard-dom', + }, '*'); + } + // Remove from DOM + iframe.remove(); + } + } + + private restoreIframe() { + // Restore iframe to DOM (quick restore from suspended) + const iframe = this.shadowRoot?.querySelector('iframe') as HTMLIFrameElement | null; + if (iframe) { + iframe.style.display = 'block'; + // Notify iframe to restore its internal DOM + if (iframe.contentWindow) { + iframe.contentWindow.postMessage({ + type: 'restore-dom', + }, '*'); + } + } + } + + private recreateIframe() { + // Recreate iframe from discarded state (slow restore) + // This will trigger a re-render, which will create a new iframe + this.requestUpdate(); + } + + render() { + // ... existing render logic ... + // Lifecycle state affects visibility but iframe remains in DOM + // (unless suspended - see DOM suspension strategy) + } +} +``` + +### 4. Iframe Lifecycle Message Handling + +**File:** `iframes/applet-iframe/src/index.ts` + +Handle lifecycle messages from parent: + +```typescript +// Listen for lifecycle state changes from parent +window.addEventListener('message', (event) => { + if (event.data.type === 'lifecycle-state-change') { + const { state, previousState } = event.data; + + // Update renderInfo lifecycle state + if (window.__WEAVE_RENDER_INFO__ && window.__WEAVE_RENDER_INFO__.type === 'applet-view') { + window.__WEAVE_RENDER_INFO__.lifecycleState = state; + } + + // Dispatch event for tools to listen to + window.dispatchEvent(new CustomEvent('weave-lifecycle-change', { + detail: { state, previousState } + })); + } + + if (event.data.type === 'suspend-dom') { + // Tool can implement DOM suspension logic + window.dispatchEvent(new CustomEvent('weave-suspend-dom')); + } +}); +``` + +### 5. Tool Implementation Pattern + +**Example tool code:** + +```typescript +import { WeaveClient } from '@theweave/api'; +import { get } from '@holochain-open-dev/stores'; + +const weaveClient = await WeaveClient.connect(appletServices); + +// Get initial lifecycle state +let currentLifecycleState = weaveClient.renderInfo.type === 'applet-view' + ? weaveClient.renderInfo.lifecycleState + : 'active'; + +// Subscribe to lifecycle changes +const unsubscribeLifecycle = weaveClient.onLifecycleChange((state) => { + const previousState = currentLifecycleState; + currentLifecycleState = state; + + console.log(`Lifecycle changed: ${previousState} → ${state}`); + + // Handle state transitions + if (state === 'active' && previousState === 'suspended') { + // Restore DOM if needed (quick restore) + restoreDOM(); + } else if (state === 'active' && previousState === 'discarded') { + // Recreate DOM (slow restore - iframe was recreated) + // Data stores should still be available + initializeDOM(); + } else if (state === 'suspended') { + // Suspend DOM to free memory + suspendDOM(); + } else if (state === 'discarded') { + // DOM is being discarded - ensure data is persisted + persistData(); + } + + // Adjust background processing based on state + // Note: Notifications can run at same frequency regardless of state + updateBackgroundProcessing(state); +}); + +// Background processing (notifications, sync, etc.) +let syncInterval: number | undefined; +let notificationInterval: number | undefined; + +function updateBackgroundProcessing(state: 'active' | 'inactive' | 'suspended' | 'discarded') { + // Notifications can run at same frequency regardless of lifecycle state + // This is tool-specific - some tools may want to slow down when suspended + const notificationIntervalMs = 30000; // 30 seconds (same for all states) + + if (!notificationInterval) { + notificationInterval = setInterval(async () => { + try { + const updates = await appletClient.callZome({ + role_name: 'forum', + zome_name: 'posts', + fn_name: 'get_recent_updates', + payload: { since: lastSyncTime }, + }); + + if (updates.length > 0) { + await weaveClient.notifyFrame([{ + title: 'New posts', + body: `${updates.length} new posts available`, + urgency: 'low', + }]); + lastSyncTime = Date.now(); + } + } catch (e) { + console.error('Background sync error:', e); + } + }, notificationIntervalMs); + } + + // Sync interval might be slower when suspended + const syncIntervalMs = state === 'suspended' ? 120000 : 30000; // 2 min vs 30 sec + + if (syncInterval) { + clearInterval(syncInterval); + syncInterval = undefined; + } + + syncInterval = setInterval(async () => { + // Sync data (but don't duplicate - use same stores as main view) + await syncData(); + }, syncIntervalMs); +} + +// DOM suspension/restoration (optional, for memory optimization) +let suspendedDOM: HTMLElement | null = null; + +function suspendDOM() { + const mainContent = document.getElementById('main-content'); + if (mainContent) { + // Store DOM in memory for quick restore + suspendedDOM = mainContent.cloneNode(true) as HTMLElement; + // Remove from document to free memory + mainContent.remove(); + } +} + +function restoreDOM() { + if (suspendedDOM) { + const container = document.getElementById('app-container'); + if (container) { + container.appendChild(suspendedDOM); + suspendedDOM = null; + } + } +} + +// Listen for suspend DOM message from parent +window.addEventListener('weave-suspend-dom', () => { + suspendDOM(); +}); + +// Initial setup +updateBackgroundProcessing(currentLifecycleState); + +// Cleanup +window.addEventListener('beforeunload', () => { + if (syncInterval) clearInterval(syncInterval); + if (notificationInterval) clearInterval(notificationInterval); + unsubscribeLifecycle(); +}); +``` + +## Addressing Original Concerns + +This proposal directly addresses all the concerns raised about the separate iframe approach: + +### 1. Data Duplication ❌ → ✅ Solved +**Problem**: Separate iframe would load its own copy of data (e.g., posts), potentially doubling memory usage. + +**Solution**: Single iframe means single source of truth. Data loaded for background processing is the same data used by the main view. No duplication. + +### 2. Race Conditions ❌ → ✅ Solved +**Problem**: If background processor makes zome calls that change data, synchronization issues between iframes. + +**Solution**: All zome calls happen in the same iframe context. No synchronization needed because there's only one context. + +### 3. Data Availability ❌ → ✅ Solved +**Problem**: Work done in background iframe won't be available to foreground iframe. + +**Solution**: Background work happens in the same iframe, so it's immediately available. When the applet becomes active, all data is already there. + +### 4. Notification Frequency ⚠️ → ✅ Solved +**Problem**: Notifications may need same frequency in background as foreground (not necessarily slower). + +**Solution**: Tools have full control. They can run notifications at the same frequency regardless of lifecycle state, or adjust as needed. The lifecycle API provides information, but tools decide how to use it. + +## Benefits + +1. **No Data Duplication**: Single iframe, single source of truth for data +2. **No Race Conditions**: All zome calls from same iframe context +3. **Data Availability**: Background work is immediately available to foreground +4. **Flexible Notification Frequency**: Tools can run notifications at same frequency regardless of lifecycle state +5. **Memory Recovery**: DOM can be suspended after inactivity period +6. **Quick Restore**: Suspended DOM can be restored quickly when applet becomes active +7. **Backward Compatible**: Tools without lifecycle handling continue to work +8. **Simpler Architecture**: One iframe to manage instead of two + +## Configuration Options + +### Inactivity Timeout + +Configurable per applet or globally: + +```typescript +// In moss config or per-applet settings +const INACTIVITY_TIMEOUT = 5 * 60 * 1000; // 5 minutes (default) +``` + +### Lifecycle Behavior + +Tools can opt-in to different behaviors: + +```typescript +// Tool can request suspension earlier +weaveClient.requestLifecycleState?.('suspended'); + +// Or request to stay active longer +// (Moss renderer has final say, but can respect tool preferences) +``` + +## Integration with Existing `backgroundProcessor` Pattern + +The existing `backgroundProcessor` function in `AppletServices` can still be used, but it will run in the **same iframe** as the main view, not a separate iframe. This means: + +1. **No separate iframe creation**: The `backgroundProcessor` function is called from the main applet iframe +2. **Lifecycle-aware execution**: The function receives lifecycle state and can adjust its behavior +3. **Shared data context**: Background processing shares the same data stores as the main view +4. **Optional execution**: Tools can choose to run background processing only when inactive/suspended, or always + +### Updated Tool Pattern + +```typescript +const appletServices: AppletServices = { + // ... other services ... + + // Background processor runs in same iframe, lifecycle-aware + backgroundProcessor: async (weaveClient, appletClient, profilesClient, lifecycle) => { + // This runs in the main applet iframe, not a separate iframe + // It can access the same data stores, DOM, etc. + + // Subscribe to lifecycle changes + lifecycle.onLifecycleChange((state) => { + if (state.isGroupActive && state.isAppVisible) { + // Tool is active - might pause background processing + // or continue at full speed (tool's choice) + } else { + // Tool is inactive - continue background processing + } + }); + + // Background processing logic (notifications, sync, etc.) + // This shares the same context as the main view + setInterval(async () => { + // ... background sync logic ... + }, 30000); + }, +}; + +const weaveClient = await WeaveClient.connect(appletServices); + +// In main view code, also subscribe to lifecycle +weaveClient.onLifecycleChange((state) => { + if (state === 'active') { + // Restore UI, resume timers, etc. + } else if (state === 'inactive' || state === 'suspended') { + // Optionally pause UI updates, but background processing continues + } +}); +``` + +## Migration Path + +1. **Phase 1**: Add lifecycle API to WeaveClient (backward compatible) +2. **Phase 2**: Implement lifecycle state management in Moss renderer +3. **Phase 3**: Update `backgroundProcessor` execution to run in main iframe instead of separate iframe +4. **Phase 4**: Tools can optionally implement lifecycle handlers +5. **Phase 5**: Enable DOM suspension for memory optimization +6. **Phase 6**: Remove separate background processor iframe creation code (cleanup) + +## Comparison with Separate Iframe Approach + +| Aspect | Separate Iframe | Single Iframe Lifecycle | +|--------|----------------|------------------------| +| Data Duplication | ❌ Yes (two copies) | ✅ No (single source) | +| Race Conditions | ❌ Possible | ✅ Not possible | +| Data Availability | ❌ Separate contexts | ✅ Shared context | +| Memory Usage | ⚠️ Higher (two iframes) | ✅ Lower (one iframe) | +| Notification Frequency | ⚠️ Limited by design | ✅ Flexible (tool decides) | +| DOM Suspension | ❌ Not applicable | ✅ Supported | +| Complexity | ⚠️ Higher (two iframes to manage) | ✅ Lower (one iframe) | + +## DOM Suspension Strategy Analysis + +The question of whether to keep DOM in memory vs discarding it requires careful consideration of memory usage vs restoration speed. + +### Option 1: Keep DOM in Memory (Suspended State) +**Approach**: Remove DOM from document but keep elements in memory for quick restore. + +**Memory Impact**: +- **Detached DOM elements still consume memory** - JavaScript objects, event listeners, and references remain +- Memory savings: **~30-50%** (removes layout/rendering overhead, but not object memory) +- Browser may garbage collect detached elements if memory pressure is high + +**Restoration Speed**: +- **Very fast** - Just reattach to document (~10-50ms) +- No need to recreate elements or re-fetch data +- State is preserved + +**Use Case**: Best when: +- User frequently switches back to applet +- Applet has complex DOM structure that's expensive to recreate +- Memory is not critically constrained + +### Option 2: Discard DOM Completely +**Approach**: Remove iframe from DOM entirely, recreate when needed. + +**Memory Impact**: +- **Maximum memory savings** - iframe and all DOM can be garbage collected +- Memory savings: **~70-90%** (only JavaScript state/data remains) +- Background processing continues (timers, data stores, etc.) + +**Restoration Speed**: +- **Slower** - Need to recreate iframe and reload content (~200-1000ms) +- May need to re-fetch data depending on tool implementation +- State may need to be restored from data stores + +**Use Case**: Best when: +- User rarely switches back to applet +- Memory is critically constrained +- Applet can quickly restore from data stores + +### Option 3: Three-State Approach (Recommended) +**Approach**: Add a third state between inactive and destroyed. + +**States**: +1. **`active`**: Full rendering, all resources active +2. **`inactive`**: DOM hidden (`display: none`), background processing continues +3. **`suspended`**: Iframe hidden, internal DOM removed but kept in memory (quick restore) +4. **`discarded`**: Iframe hidden, internal DOM cleared, but iframe and JavaScript context remain alive for background processing (medium restore) + +**Note**: When applets are uninstalled/disabled/abandoned, iframes are simply removed - no lifecycle state needed. + +**State Transitions**: +``` +active → inactive (immediate when deselected or user switches groups) +inactive → suspended (after 5 minutes) +suspended → discarded (after 30 minutes, or on memory pressure) +suspended → active (quick restore ~10-50ms when user selects applet again) +discarded → active (medium restore ~50-200ms, restore iframe visibility and rebuild DOM) +``` + +**Important**: +- Applets remain in lifecycle states even when their group is not the active group. They continue background processing and can become active again when the user switches back to that group and selects the applet. +- In `discarded` state, the iframe stays in the DOM (hidden) so the JavaScript context remains alive and background processing (notifications, timers, etc.) continues to work. + +**Memory vs Speed Trade-off**: +- **`inactive`**: No memory savings, instant restore +- **`suspended`**: ~30-50% memory savings, ~10-50ms restore +- **`discarded`**: ~70-90% memory savings (DOM cleared), ~50-200ms restore (iframe stays in DOM, just hidden) + +**Implementation**: +```typescript +// Configuration +private readonly INACTIVITY_TO_SUSPENDED = 5 * 60 * 1000; // 5 minutes +private readonly SUSPENDED_TO_DISCARDED = 30 * 60 * 1000; // 30 minutes + +// Or use memory pressure detection +private checkMemoryPressure(): boolean { + // Use performance.memory API if available (Chrome) + if ('memory' in performance) { + const memory = (performance as any).memory; + const usedMB = memory.usedJSHeapSize / 1048576; + const totalMB = memory.totalJSHeapSize / 1048576; + return usedMB / totalMB > 0.8; // 80% memory usage + } + return false; +} +``` + +### Recommendation: Three-State Approach + +**Rationale**: +1. **Flexibility**: Balances memory savings with restoration speed +2. **User Experience**: Quick restore for recently used applets, memory savings for long-unused ones +3. **Adaptive**: Can transition to `discarded` on memory pressure +4. **Tool Control**: Tools can opt-in to faster/slower transitions based on their needs + +**Default Behavior**: +- **`inactive`** (0-5 min): Keep DOM, instant restore +- **`suspended`** (5-30 min): Remove DOM from document, quick restore (~10-50ms) +- **`discarded`** (30+ min or memory pressure): Remove iframe, slow restore (~200-1000ms) + +**Tool Override**: +```typescript +// Tool can request different behavior +weaveClient.requestLifecycleState?.('suspended'); // Suspend earlier +weaveClient.requestLifecycleState?.('inactive'); // Stay active longer +``` + +## Important Design Decisions + +1. **No "destroyed" state**: When applets are uninstalled, disabled, or abandoned, iframes are simply removed from the DOM. Lifecycle states only apply to active applets that may become active again. + +2. **Group switching doesn't destroy applets**: When users switch between groups, applets in the previous group go to `inactive` state (not destroyed). They continue background processing and can become active again when the user switches back. + +3. **Lifecycle based on selection, not group**: An applet is `active` when it's the selected applet in the currently viewed group. It's `inactive` when not selected, regardless of which group is active. + +## Open Questions + +1. **DOM Suspension Strategy**: Three-state approach recommended (inactive → suspended → discarded) +2. **Inactivity Timeout**: Default 5 minutes to suspended, 30 minutes to discarded (configurable) +3. **State Persistence**: Should suspended/discarded state persist across app restarts? (Probably not - start fresh) +4. **Resource Monitoring**: Should lifecycle state be influenced by system resource usage? (Yes - transition to discarded on memory pressure) +5. **Tool Opt-in**: Should tools be able to disable DOM suspension entirely? (Yes - for critical real-time tools) + +## Related Documents + +- [Background Processing Proposal](./BACKGROUND_PROCESSING_PROPOSAL.md) - Original separate iframe proposal +- [Performance Optimization Plan](./PERFORMANCE_OPTIMIZATION_PLAN.md) - Overall performance goals +- [Background Processor Notification Analysis](./BACKGROUND_PROCESSOR_NOTIFICATION_ANALYSIS.md) - Notification patterns + diff --git a/BACKGROUND_PROCESSOR_CONSOLE_SCRIPT.js b/BACKGROUND_PROCESSOR_CONSOLE_SCRIPT.js new file mode 100644 index 00000000..1015d713 --- /dev/null +++ b/BACKGROUND_PROCESSOR_CONSOLE_SCRIPT.js @@ -0,0 +1,278 @@ +/** + * Background Processor Measurement - Browser Console Script + * + * Paste this script into the browser console to measure performance + * before and after implementing background processors. + * + * This script works independently and doesn't require app integration. + * + * Usage: + * 1. Before implementation: Run measureBefore() + * 2. After implementation: Run measureAfter() + * 3. Compare: Run compareResults() + */ + +(function() { + 'use strict'; + + // Storage for measurements + const measurements = { + before: null, + after: null, + }; + + // Helper to get all iframes + function getAllIframes() { + const result = []; + function traverse(node) { + if (node.tagName === 'IFRAME') { + result.push(node); + } + const shadowRoot = node.shadowRoot; + if (shadowRoot) { + shadowRoot.childNodes.forEach(traverse); + } + node.childNodes.forEach(traverse); + } + traverse(document.body); + return result; + } + + // Take a measurement snapshot + function takeSnapshot(label) { + const iframes = getAllIframes(); + const memory = performance.memory?.usedJSHeapSize; + + const snapshot = { + timestamp: performance.now(), + label: label, + iframeCount: iframes.length, + memoryMB: memory ? (memory / 1024 / 1024).toFixed(2) : 'N/A', + memoryBytes: memory, + }; + + console.log(`[PERF] ${label} snapshot:`, snapshot); + return snapshot; + } + + // Complete before measurement + window.measureBeforeComplete = function() { + if (!measurements.before) { + console.error('[PERF] No before measurement in progress. Call measureBefore() first.'); + return; + } + + if (measurements.before.interval) { + clearInterval(measurements.before.interval); + measurements.before.interval = null; + } + + measurements.before.snapshots.push(takeSnapshot('before-final')); + measurements.before.endTime = performance.now(); + measurements.before.duration = measurements.before.endTime - measurements.before.startTime; + + const avgIframes = measurements.before.snapshots.reduce((sum, s) => sum + s.iframeCount, 0) / measurements.before.snapshots.length; + const maxIframes = Math.max(...measurements.before.snapshots.map(s => s.iframeCount)); + const memorySnapshots = measurements.before.snapshots.filter(s => s.memoryBytes); + const avgMemory = memorySnapshots.length > 0 + ? memorySnapshots.reduce((sum, s) => sum + s.memoryBytes, 0) / memorySnapshots.length + : null; + + measurements.before.summary = { + avgIframeCount: avgIframes.toFixed(1), + maxIframeCount: maxIframes, + avgMemoryMB: avgMemory ? (avgMemory / 1024 / 1024).toFixed(2) : 'N/A', + duration: measurements.before.duration.toFixed(2) + 'ms', + }; + + console.log('[PERF] BEFORE measurement complete!'); + console.table(measurements.before.summary); + console.log('[PERF] Data saved. After implementation, call measureAfter()'); + }; + + // Measure before implementation + window.measureBefore = function() { + console.log('[PERF] Starting BEFORE measurement...'); + console.log('[PERF] Navigate to a group with multiple applets, then call measureBeforeComplete()'); + + measurements.before = { + startTime: performance.now(), + snapshots: [], + interval: null, + }; + + // Take initial snapshot + measurements.before.snapshots.push(takeSnapshot('before-initial')); + + // Set up periodic snapshots + measurements.before.interval = setInterval(() => { + measurements.before.snapshots.push(takeSnapshot('before-periodic')); + }, 500); + }; + + // Complete after measurement + window.measureAfterComplete = function() { + if (!measurements.after) { + console.error('[PERF] No after measurement in progress. Call measureAfter() first.'); + return; + } + + if (measurements.after.interval) { + clearInterval(measurements.after.interval); + measurements.after.interval = null; + } + + measurements.after.snapshots.push(takeSnapshot('after-final')); + measurements.after.endTime = performance.now(); + measurements.after.duration = measurements.after.endTime - measurements.after.startTime; + + const avgIframes = measurements.after.snapshots.reduce((sum, s) => sum + s.iframeCount, 0) / measurements.after.snapshots.length; + const maxIframes = Math.max(...measurements.after.snapshots.map(s => s.iframeCount)); + const memorySnapshots = measurements.after.snapshots.filter(s => s.memoryBytes); + const avgMemory = memorySnapshots.length > 0 + ? memorySnapshots.reduce((sum, s) => sum + s.memoryBytes, 0) / memorySnapshots.length + : null; + + measurements.after.summary = { + avgIframeCount: avgIframes.toFixed(1), + maxIframeCount: maxIframes, + avgMemoryMB: avgMemory ? (avgMemory / 1024 / 1024).toFixed(2) : 'N/A', + duration: measurements.after.duration.toFixed(2) + 'ms', + }; + + console.log('[PERF] AFTER measurement complete!'); + console.table(measurements.after.summary); + console.log('[PERF] Call compareResults() to see comparison'); + }; + + // Measure after implementation + window.measureAfter = function() { + console.log('[PERF] Starting AFTER measurement...'); + console.log('[PERF] Navigate to the same group, then call measureAfterComplete()'); + + measurements.after = { + startTime: performance.now(), + snapshots: [], + interval: null, + }; + + // Take initial snapshot + measurements.after.snapshots.push(takeSnapshot('after-initial')); + + // Set up periodic snapshots + measurements.after.interval = setInterval(() => { + measurements.after.snapshots.push(takeSnapshot('after-periodic')); + }, 500); + }; + + // Compare before and after + window.compareResults = function() { + if (!measurements.before || !measurements.after) { + console.error('[PERF] Need both before and after measurements. Run measureBefore() and measureAfter() first.'); + return; + } + + const before = measurements.before.summary; + const after = measurements.after.summary; + + const iframeReduction = parseFloat(before.avgIframeCount) - parseFloat(after.avgIframeCount); + const iframeReductionPercent = (iframeReduction / parseFloat(before.avgIframeCount) * 100).toFixed(1); + + let memoryReduction = 'N/A'; + let memoryReductionPercent = 'N/A'; + if (before.avgMemoryMB !== 'N/A' && after.avgMemoryMB !== 'N/A') { + const beforeMB = parseFloat(before.avgMemoryMB); + const afterMB = parseFloat(after.avgMemoryMB); + const reductionMB = beforeMB - afterMB; + memoryReduction = reductionMB.toFixed(2) + ' MB'; + memoryReductionPercent = ((reductionMB / beforeMB) * 100).toFixed(1) + '%'; + } + + console.log('\n[PERF] === BEFORE/AFTER COMPARISON ===\n'); + console.table({ + 'Metric': [ + 'Average Iframe Count', + 'Max Iframe Count', + 'Average Memory (MB)', + 'Measurement Duration', + ], + 'Before': [ + before.avgIframeCount, + before.maxIframeCount, + before.avgMemoryMB, + before.duration, + ], + 'After': [ + after.avgIframeCount, + after.maxIframeCount, + after.avgMemoryMB, + after.duration, + ], + 'Change': [ + `${iframeReduction > 0 ? '-' : '+'}${Math.abs(iframeReduction).toFixed(1)} (${iframeReductionPercent}%)`, + (before.maxIframeCount - after.maxIframeCount) + '', + memoryReduction + ' (' + memoryReductionPercent + ')', + ((parseFloat(after.duration) - parseFloat(before.duration)) / 1000).toFixed(2) + 's', + ], + }); + + console.log('\n[PERF] Expected improvements:'); + console.log(' - Memory: 70-85% reduction'); + console.log(' - CPU: 50-70% reduction during setup'); + console.log(' - Main view iframes: Significant reduction'); + console.log(' - Background processor iframes: New (lightweight)'); + }; + + // Export measurements as JSON + window.exportMeasurements = function() { + const data = JSON.stringify(measurements, null, 2); + console.log('[PERF] Measurements JSON:'); + console.log(data); + return data; + }; + + // Import measurements from JSON + window.importMeasurements = function(jsonString) { + try { + measurements = JSON.parse(jsonString); + console.log('[PERF] Measurements imported successfully'); + } catch (e) { + console.error('[PERF] Failed to import measurements:', e); + } + }; + + // Stop current measurement (without completing) + window.stopMeasurement = function() { + if (measurements.before && measurements.before.interval) { + clearInterval(measurements.before.interval); + measurements.before.interval = null; + console.log('[PERF] Stopped before measurement. Call measureBeforeComplete() to finalize.'); + } + if (measurements.after && measurements.after.interval) { + clearInterval(measurements.after.interval); + measurements.after.interval = null; + console.log('[PERF] Stopped after measurement. Call measureAfterComplete() to finalize.'); + } + if (!measurements.before?.interval && !measurements.after?.interval) { + console.log('[PERF] No active measurement to stop.'); + } + }; + + // Quick single measurement + window.quickMeasure = function() { + return takeSnapshot('quick'); + }; + + console.log('[PERF] Background Processor Measurement Script Loaded!'); + console.log('[PERF] Available functions:'); + console.log(' - measureBefore() - Start before measurement'); + console.log(' - measureBeforeComplete() - Complete before measurement'); + console.log(' - measureAfter() - Start after measurement'); + console.log(' - measureAfterComplete() - Complete after measurement'); + console.log(' - stopMeasurement() - Stop current measurement'); + console.log(' - compareResults() - Compare before/after'); + console.log(' - quickMeasure() - Take a single snapshot'); + console.log(' - exportMeasurements() - Export as JSON'); + console.log(' - importMeasurements(jsonString) - Import from JSON'); +})(); + diff --git a/BACKGROUND_PROCESSOR_IMPLEMENTATION_NOTES.md b/BACKGROUND_PROCESSOR_IMPLEMENTATION_NOTES.md new file mode 100644 index 00000000..08620bf2 --- /dev/null +++ b/BACKGROUND_PROCESSOR_IMPLEMENTATION_NOTES.md @@ -0,0 +1,90 @@ +# Background Processor Implementation Notes + +## Implementation Status + +The background processor proposal has been partially implemented. The following components are in place: + +### ✅ Completed + +1. **API Extensions** + - `AppletServices.backgroundProcessor` field added + - `RenderInfo` type extended with `background-processor` type + - `BackgroundProcessorLifecycle` interface added + - `IframeConfig` extended with `groupDnaHash` for background processors + +2. **Iframe Support** + - Background processor renderInfo handling in iframe index.ts + - Query string parsing for background-processor view type + - RenderView type extended + +3. **Lifecycle Management** + - `createBackgroundProcessorLifecycle()` function created + - Tracks app visibility, group active state, and resource state + - Lifecycle change callbacks supported + +4. **Example Applet** + - Background processor example added to example applet + - Demonstrates periodic sync with lifecycle-aware throttling + - Shows notification usage + +### ⚠️ Partial / Future Work + +1. **Automatic Background Processor Creation** + - Currently, background processors need to be manually created + - Future: Automatically create background processor iframes when applets with `backgroundProcessor` are loaded + - This would require: + - Detecting when an applet with backgroundProcessor is loaded + - Creating a hidden iframe for the background processor + - Executing the background processor function in that iframe + +2. **Lifecycle Integration** + - Group active state tracking needs integration with MossStore + - Resource state monitoring needs implementation + - Currently defaults to "normal" state + +3. **View Frame Rendering** + - View-frame.ts can handle background-processor renderViews + - But background processors should ideally be created as separate hidden iframes + - Not through the normal view rendering flow + +## Testing the Implementation + +To test the background processor: + +1. **Rebuild the example applet:** + ```bash + yarn build:example-applet + ``` + +2. **Run in dev mode:** + ```bash + yarn applet-dev-example + ``` + +3. **Check console logs:** + - Look for `[Background Processor]` log messages + - Verify lifecycle changes are detected + - Check that notifications are sent + +## Manual Background Processor Creation + +Currently, background processors need to be created manually. To create one: + +1. Create a hidden iframe with `view=background-processor` in the query string +2. The iframe will automatically execute the `backgroundProcessor` function from `AppletServices` +3. The processor will run independently of the main view + +## Next Steps + +1. Implement automatic background processor creation when applets are loaded +2. Integrate lifecycle state with MossStore for accurate group active tracking +3. Implement resource state monitoring +4. Add background processor management UI (optional) + +## Notes + +- Background processors are designed to be lightweight and persistent +- They continue running even when the main applet view is not rendered +- Lifecycle throttling allows processors to reduce activity when appropriate +- The example applet demonstrates a simple sync pattern with notifications + diff --git a/BACKGROUND_PROCESSOR_MEASUREMENT_SUMMARY.md b/BACKGROUND_PROCESSOR_MEASUREMENT_SUMMARY.md new file mode 100644 index 00000000..a108954c --- /dev/null +++ b/BACKGROUND_PROCESSOR_MEASUREMENT_SUMMARY.md @@ -0,0 +1,175 @@ +# Background Processor Measurement Summary + +This document provides a quick reference for measuring performance before and after implementing background processors. + +## Files Created + +1. **`src/renderer/src/utils/background-processor-metrics.ts`** + - Core metrics tracking class + - Tracks iframe counts, memory usage, timing metrics, lifecycle state + - Provides comparison functionality + +2. **`src/renderer/src/utils/background-processor-measurement-script.ts`** + - Helper functions for easy measurement + - Console helpers setup + - Automated measurement workflows + +3. **`BACKGROUND_PROCESSOR_CONSOLE_SCRIPT.js`** + - Standalone browser console script + - Can be pasted directly into console + - No app integration required + +4. **`BACKGROUND_PROCESSOR_MEASUREMENT_USAGE.md`** + - Detailed usage guide + - Integration instructions + - Troubleshooting + +## Quick Start (3 Methods) + +### Method 1: Standalone Console Script (Easiest) + +1. Open browser console +2. Paste contents of `BACKGROUND_PROCESSOR_CONSOLE_SCRIPT.js` +3. Before implementation: + ```javascript + measureBefore(); + // Navigate to group, wait for applets to load + measureBeforeComplete(); + ``` +4. After implementation: + ```javascript + measureAfter(); + // Navigate to same group, wait for applets to load + measureAfterComplete(); + compareResults(); + ``` + +### Method 2: Integrated Console Helpers + +1. Add to app initialization: + ```typescript + import { setupConsoleHelpers } from './utils/background-processor-measurement-script.js'; + setupConsoleHelpers(mossStore, () => groupStore); + ``` + +2. In browser console: + ```javascript + window.__backgroundProcessorMeasurement.startBefore(); + // ... wait ... + window.__backgroundProcessorMeasurement.stop(); + window.__backgroundProcessorMeasurement.summary(); + const beforeData = window.__backgroundProcessorMeasurement.export(); + + // After implementation: + window.__backgroundProcessorMeasurement.startAfter(); + // ... wait ... + window.__backgroundProcessorMeasurement.stop(); + window.__backgroundProcessorMeasurement.compare(beforeData); + ``` + +### Method 3: Programmatic + +```typescript +import { + startMeasurement, + stopMeasurement, + printSummary, + printComparison, + exportMetrics, +} from './utils/background-processor-measurement-script.js'; + +// Before +startMeasurement('before', mossStore, groupStore); +// ... wait ... +stopMeasurement(); +const beforeData = exportMetrics(); + +// After +startMeasurement('after', mossStore, groupStore); +// ... wait ... +stopMeasurement(); +printComparison(beforeData); +``` + +## What Gets Measured + +### Resource Metrics +- Total iframe count +- Applet iframe count +- Background processor iframe count (after implementation) +- Main view iframe count +- Memory usage (Chrome/Edge only) +- Applets loaded count + +### Timing Metrics +- Group setup time +- Running applets load time +- Applet store initialization time +- Applet iframe load time +- Background processor initialization time + +### Lifecycle State +- App visibility +- Group active state +- Resource state (normal/constrained/critical) + +## Expected Results + +Based on `BACKGROUND_PROCESSING_PERFORMANCE_ANALYSIS.md`: + +| Metric | Before | After | Improvement | +|--------|--------|-------|-------------| +| Memory (10 applets) | 50-150 MB | 15-45 MB | 70-85% reduction | +| CPU during setup | High | Lower | 50-70% reduction | +| Main view iframes | ~10 | ~1 | 90% reduction | +| Background processor iframes | 0 | ~10 | New (lightweight) | +| Total iframes | ~10 | ~11 | Slight increase | + +## Measurement Workflow + +1. **Prepare test environment** + - Group with 5-10 running applets + - Clear cache + - Close other tabs + - Disable extensions + +2. **Before measurement** + - Start measurement + - Navigate to group + - Wait for all applets to load + - Stop measurement + - Export and save results + +3. **Implement background processors** + - Follow `BACKGROUND_PROCESSING_PROPOSAL.md` + +4. **After measurement** + - Use same test environment + - Start measurement + - Navigate to same group + - Wait for all applets to load + - Stop measurement + - Compare with before results + +## Key Metrics to Watch + +1. **Memory Usage** - Should decrease 70-85% +2. **Main View Iframes** - Should decrease significantly (only 1 active) +3. **Background Processor Iframes** - Should appear (lightweight) +4. **Time to First Applet Ready** - Should improve (only 1 main view to load) +5. **Frame Rate** - Should stay smooth during setup + +## Troubleshooting + +- **Memory shows "N/A"**: Only available in Chrome/Edge. Use DevTools Memory tab. +- **Iframe counts wrong**: Wait for all applets to load before stopping measurement. +- **No improvement**: Verify background processors are implemented and lifecycle throttling works. + +## Related Documents + +- [Background Processing Measurement Guide](./BACKGROUND_PROCESSING_MEASUREMENT_GUIDE.md) - What can/cannot be measured +- [Background Processing Proposal](./BACKGROUND_PROCESSING_PROPOSAL.md) - Implementation details +- [Background Processing Performance Analysis](./BACKGROUND_PROCESSING_PERFORMANCE_ANALYSIS.md) - Expected benefits +- [Background Processor Measurement Usage](./BACKGROUND_PROCESSOR_MEASUREMENT_USAGE.md) - Detailed usage guide +- [Performance Measurement Guide](./PERFORMANCE_MEASUREMENT_GUIDE.md) - General performance measurement + diff --git a/BACKGROUND_PROCESSOR_MEASUREMENT_USAGE.md b/BACKGROUND_PROCESSOR_MEASUREMENT_USAGE.md new file mode 100644 index 00000000..7e233217 --- /dev/null +++ b/BACKGROUND_PROCESSOR_MEASUREMENT_USAGE.md @@ -0,0 +1,338 @@ +# Background Processor Measurement Usage Guide + +This guide explains how to use the measurement utilities to compare performance before and after implementing background processors. + +## Overview + +The measurement system consists of: +1. **BackgroundProcessorMetrics** - Core metrics tracking class +2. **Measurement Script** - Helper functions for easy measurement +3. **Console Helpers** - Browser console utilities + +## Quick Start + +### Method 1: Browser Console (Recommended for Quick Tests) + +1. **Set up console helpers** (add to your app initialization): +```typescript +import { setupConsoleHelpers } from './utils/background-processor-measurement-script.js'; +import { mossStore } from './moss-store.js'; + +// In your app initialization +setupConsoleHelpers(mossStore, () => groupStore); // groupStore from context +``` + +2. **In browser console, run before measurement:** +```javascript +// Start before measurement (takes snapshots every 500ms) +window.__backgroundProcessorMeasurement.startBefore(); + +// Wait for applets to load, then stop +window.__backgroundProcessorMeasurement.stop(); + +// View summary +window.__backgroundProcessorMeasurement.summary(); + +// Export for later comparison +const beforeData = window.__backgroundProcessorMeasurement.export(); +// Copy this JSON string and save it somewhere +``` + +3. **After implementing background processors, run after measurement:** +```javascript +// Start after measurement +window.__backgroundProcessorMeasurement.startAfter(); + +// Wait for applets to load, then stop +window.__backgroundProcessorMeasurement.stop(); + +// View summary +window.__backgroundProcessorMeasurement.summary(); + +// Compare with before (paste the beforeData JSON string) +window.__backgroundProcessorMeasurement.compare(beforeData); +``` + +### Method 2: Programmatic Measurement + +```typescript +import { + backgroundProcessorMetrics, + startMeasurement, + stopMeasurement, + printSummary, + printComparison, + exportMetrics, + importMetrics, +} from './utils/background-processor-measurement-script.js'; + +// BEFORE IMPLEMENTATION +// Start before phase +startMeasurement('before', mossStore, groupStore, 500); // 500ms snapshot interval + +// ... let applets load ... + +// Stop and get summary +stopMeasurement(); +printSummary(); + +// Export before metrics +const beforeMetricsJson = exportMetrics(); +// Save this to a file or localStorage +localStorage.setItem('beforeMetrics', beforeMetricsJson); + +// AFTER IMPLEMENTATION +// Load before metrics +const beforeMetricsJson = localStorage.getItem('beforeMetrics'); +const beforeMetrics = JSON.parse(beforeMetricsJson); + +// Start after phase +startMeasurement('after', mossStore, groupStore, 500); + +// ... let applets load ... + +// Stop and compare +stopMeasurement(); +printSummary(); + +// Import before metrics and compare +importMetrics(beforeMetricsJson); +printComparison(beforeMetrics); +``` + +### Method 3: Automated Measurement + +```typescript +import { automatedMeasurement } from './utils/background-processor-measurement-script.js'; + +// Before implementation +await automatedMeasurement('before', mossStore, groupStore, 10000); // 10 seconds +const beforeData = exportMetrics(); + +// After implementation +await automatedMeasurement('after', mossStore, groupStore, 10000); +const afterData = exportMetrics(); + +// Compare +importMetrics(beforeData); +printComparison(beforeData); +``` + +## What Gets Measured + +### Resource Metrics + +- **Total Iframe Count** - All iframes in the DOM +- **Applet Iframe Count** - Iframes for applets +- **Background Processor Iframe Count** - Iframes running background processors +- **Main View Iframe Count** - Iframes showing main applet views +- **Memory Usage** - JavaScript heap size (if available in Chrome) +- **Applets Loaded** - Number of applets currently loaded +- **Applets with Background Processors** - Number of applets that have background processors + +### Timing Metrics + +Uses the existing `perfLogger` to track: +- Group setup time +- Running applets load time +- Applet store initialization time +- Applet iframe load time +- Background processor initialization time +- Lifecycle state change response time + +### Lifecycle State + +Tracks: +- App visibility (isAppVisible) +- Group active state (isGroupActive) +- Resource state (normal/constrained/critical) + +## Measurement Workflow + +### Step 1: Baseline Measurement (Before Implementation) + +1. **Prepare test environment:** + - Use a group with 5-10 running applets + - Clear browser cache + - Close other tabs + - Disable browser extensions + +2. **Start measurement:** + ```javascript + window.__backgroundProcessorMeasurement.startBefore(); + ``` + +3. **Navigate to the group** (or refresh if already there) + +4. **Wait for all applets to load** (watch console for completion) + +5. **Stop measurement:** + ```javascript + window.__backgroundProcessorMeasurement.stop(); + ``` + +6. **View summary:** + ```javascript + window.__backgroundProcessorMeasurement.summary(); + ``` + +7. **Export and save:** + ```javascript + const beforeData = window.__backgroundProcessorMeasurement.export(); + // Copy this JSON and save it + console.log(beforeData); + ``` + +### Step 2: Implementation + +Implement background processors according to `BACKGROUND_PROCESSING_PROPOSAL.md`. + +### Step 3: After Measurement + +1. **Use the same test environment:** + - Same group + - Same applets + - Same browser conditions + +2. **Start measurement:** + ```javascript + window.__backgroundProcessorMeasurement.startAfter(); + ``` + +3. **Navigate to the group** + +4. **Wait for all applets to load** + +5. **Stop measurement:** + ```javascript + window.__backgroundProcessorMeasurement.stop(); + ``` + +6. **View summary:** + ```javascript + window.__backgroundProcessorMeasurement.summary(); + ``` + +7. **Compare with before:** + ```javascript + // Paste the beforeData JSON string + window.__backgroundProcessorMeasurement.compare(beforeData); + ``` + +## Expected Results + +Based on `BACKGROUND_PROCESSING_PERFORMANCE_ANALYSIS.md`, you should see: + +### Iframe Count +- **Before:** ~10 iframes (one per applet) +- **After:** ~11 iframes (10 background processors + 1 main view) +- **Note:** The total increases slightly, but main view iframes decrease significantly + +### Memory Usage +- **Before:** 50-150 MB for 10 applets +- **After:** 15-45 MB for 10 applets +- **Reduction:** 70-85% + +### CPU Usage +- **Before:** High CPU during setup (all iframes loading) +- **After:** Lower CPU (background processors load separately) +- **Reduction:** 50-70% during setup + +### Timing Improvements +- Faster time to first applet ready (only one main view to load) +- Smoother frame rate during setup +- Less main thread blocking + +## Integration with Existing Performance Markers + +The measurement system works alongside existing performance markers in: +- `group-store.ts` - Group setup and running applets +- `moss-store.ts` - Applet store initialization +- `view-frame.ts` - Applet iframe loading + +All timing metrics from `perfLogger` are automatically included in comparisons. + +## Advanced Usage + +### Custom Snapshot Intervals + +```typescript +// Take snapshots every 100ms for more detailed tracking +startMeasurement('before', mossStore, groupStore, 100); +``` + +### Manual Snapshots + +```typescript +import { backgroundProcessorMetrics } from './utils/background-processor-metrics.js'; + +// Take a single snapshot +backgroundProcessorMetrics.snapshotResources(mossStore, groupStore); + +// Take lifecycle snapshot +backgroundProcessorMetrics.snapshotLifecycle({ + isAppVisible: true, + isGroupActive: true, + resourceState: 'normal', +}); +``` + +### Export/Import for Later Analysis + +```typescript +// Export measurements +const data = backgroundProcessorMetrics.export(); +const json = JSON.stringify(data, null, 2); + +// Save to file or localStorage +localStorage.setItem('measurements', json); + +// Later, import for comparison +const savedJson = localStorage.getItem('measurements'); +const savedData = JSON.parse(savedJson); +backgroundProcessorMetrics.import(savedData); +``` + +### Programmatic Comparison + +```typescript +import { BackgroundProcessorMetrics } from './utils/background-processor-metrics.js'; + +const beforeMetrics = new BackgroundProcessorMetrics(); +beforeMetrics.import(beforeData); + +const afterMetrics = new BackgroundProcessorMetrics(); +afterMetrics.import(afterData); + +const comparison = afterMetrics.compare(beforeMetrics); +console.log('Iframe reduction:', comparison.iframeReduction); +console.log('Memory reduction:', comparison.memoryReduction); +console.log('Timing improvements:', comparison.timingImprovements); +``` + +## Troubleshooting + +### Memory metrics show "N/A" +- Memory API is only available in Chrome/Edge +- Use DevTools Memory tab for manual measurement + +### Iframe counts seem wrong +- Make sure all iframes are loaded before taking snapshots +- Check that background processor iframes are properly tagged (once implemented) + +### Timing metrics missing +- Ensure `perfLogger` is being used in relevant code paths +- Check that performance markers are set correctly + +### Comparison shows no improvement +- Verify that background processors are actually implemented +- Check that main view iframes are being destroyed when not selected +- Ensure lifecycle throttling is working + +## Related Documents + +- [Background Processing Measurement Guide](./BACKGROUND_PROCESSING_MEASUREMENT_GUIDE.md) - What can and cannot be measured +- [Background Processing Proposal](./BACKGROUND_PROCESSING_PROPOSAL.md) - Implementation details +- [Background Processing Performance Analysis](./BACKGROUND_PROCESSING_PERFORMANCE_ANALYSIS.md) - Expected benefits +- [Performance Measurement Guide](./PERFORMANCE_MEASUREMENT_GUIDE.md) - General performance measurement + diff --git a/BACKGROUND_PROCESSOR_NOTIFICATION_ANALYSIS.md b/BACKGROUND_PROCESSOR_NOTIFICATION_ANALYSIS.md new file mode 100644 index 00000000..2e64f286 --- /dev/null +++ b/BACKGROUND_PROCESSOR_NOTIFICATION_ANALYSIS.md @@ -0,0 +1,156 @@ +# Background Processor Notification Analysis + +## Current Implementation Analysis + +After reviewing the example applet code, here's what I found regarding notifications and duplicate implementation: + +## 1. Notification Behavior: Applet in Front vs Not Active + +### When Applet is IN FRONT (Main View Active): + +**Two separate notification mechanisms are active:** + +#### A. Background Processor Notifications +- **Location:** `example/ui/index.html` lines 178-248 +- **Polling Frequency:** + - Every **30 seconds** when app is visible AND group is active + - Every **60 seconds** when resources are constrained + - Every **2 minutes** when group is not active + - **Paused** when app is not visible or resources are critical +- **Mechanism:** + - Polls `getAllPosts()` + - Compares post timestamps against `lastSyncTime` + - Sends notification: `"New Posts"` with count of new posts + - Updates `lastSyncTime` after notification +- **Notification Details:** + ```javascript + { + title: 'New Posts', + body: `${newPosts.length} new post(s) available`, + notification_type: 'new-posts', + urgency: 'low', + timestamp: Date.now() + } + ``` + +#### B. PostSummary Component Notifications (Main View) +- **Location:** `example/ui/src/elements/post-summary.ts` lines 46-74 +- **Trigger:** When a post is **rendered** in the UI +- **Mechanism:** + - Uses `PostsStore.allPosts` which polls every **2 seconds** (`lazyLoadAndPoll` with 2000ms interval) + - When a post is rendered, checks `localStorage.getItem('knownPosts')` + - If post hash is not in knownPosts, sends notification + - Adds post hash to localStorage +- **Notification Details:** + ```javascript + { + title: 'New Post', + body: `Heyho: ${author} created a new Post: "${title}"`, + notification_type: 'new post', + urgency: 'high', + timestamp: entryRecord.action.timestamp + } + ``` + +**Result when applet is in front:** +- **DUPLICATE NOTIFICATIONS** - Both mechanisms will fire: + 1. Background processor sends "New Posts" (low urgency) every 30 seconds when new posts found + 2. PostSummary sends "New Post" (high urgency) immediately when post appears in UI (triggered by 2-second polling) + +### When Applet is NOT ACTIVE (Main View Not Rendered): + +**Only Background Processor is active:** + +- Main view iframe is destroyed/not rendered +- `PostSummary` component doesn't exist, so no notifications from it +- `PostsStore` polling may or may not be active (depends on iframe lifecycle) +- Background processor continues running in separate iframe +- Polls every **2 minutes** when group not active (or pauses if app not visible) + +**Result when applet is not active:** +- **SINGLE NOTIFICATION SOURCE** - Only background processor sends notifications +- User gets notified about new posts even when applet view is not open +- This is the intended behavior for background processing + +## 2. Duplicate Implementation Issues + +### YES - There is duplicate/overlapping functionality: + +#### Duplicate Polling: +1. **Background Processor:** Polls `getAllPosts()` every 30s-2min +2. **PostsStore.allPosts:** Polls `getAllPosts()` every **2 seconds** (via `lazyLoadAndPoll`) + +#### Duplicate Notification Logic: +1. **Background Processor:** + - Tracks `lastSyncTime` (in-memory variable) + - Sends aggregated notification: "X new posts available" + - Updates `lastSyncTime` after notification + +2. **PostSummary Component:** + - Tracks known posts in `localStorage.getItem('knownPosts')` + - Sends individual notification per post: "Author created a new Post: Title" + - Updates localStorage after notification + +### Problems: + +1. **Different tracking mechanisms:** + - Background processor uses in-memory `lastSyncTime` (resets on reload) + - PostSummary uses `localStorage` (persists across reloads) + - These are **not synchronized**, so they can get out of sync + +2. **Different notification styles:** + - Background processor: Aggregated, low urgency + - PostSummary: Individual, high urgency + - User may receive both for the same post + +3. **Inefficient polling:** + - PostsStore polls every 2 seconds (very frequent) + - Background processor polls every 30 seconds + - Both are calling the same `getAllPosts()` function + - When applet is in front, this means polling every 2 seconds + every 30 seconds + +4. **Race conditions:** + - If a new post appears: + - PostSummary might notify first (2-second poll catches it) + - Background processor might notify 30 seconds later (if it hasn't updated `lastSyncTime` yet) + - Or vice versa depending on timing + +## Recommendations + +### Option 1: Disable PostSummary Notifications When Background Processor is Active +- Check if background processor exists +- Only show PostSummary notifications if no background processor +- This prevents duplicates but keeps both mechanisms available + +### Option 2: Use Shared State +- Share `lastSyncTime` or `knownPosts` between background processor and main view +- Use a shared storage mechanism (localStorage, IndexedDB, or postMessage) +- Ensure both use the same tracking mechanism + +### Option 3: Make Background Processor Primary +- Remove PostSummary notification logic +- Rely entirely on background processor for notifications +- Main view just displays posts (no notification logic) + +### Option 4: Differentiate Use Cases +- Background processor: Notifications when applet NOT active (background) +- PostSummary: Notifications when applet IS active (immediate feedback) +- But need to prevent duplicates by sharing state + +## Current Code Locations + +- **Background Processor:** `example/ui/index.html` lines 178-248 +- **PostsStore Polling:** `example/ui/src/posts-store.ts` lines 18-21 (2 second interval) +- **PostSummary Notifications:** `example/ui/src/elements/post-summary.ts` lines 46-74 +- **Main View:** `example/ui/src/applet-main.ts` (no polling, just renders UI) + +## Summary + +**Answer to Question 1:** When applet is in front, users get notifications from BOTH mechanisms (duplicates possible). When not active, only background processor sends notifications. + +**Answer to Question 2:** YES, there is duplicate implementation: +- Two separate polling mechanisms (2 seconds vs 30 seconds) +- Two separate notification systems (PostSummary vs Background Processor) +- Two separate tracking mechanisms (localStorage vs in-memory variable) +- Both can fire for the same new post, causing duplicate notifications + diff --git a/DISCARDED_STATE_MEMORY_ANALYSIS.md b/DISCARDED_STATE_MEMORY_ANALYSIS.md new file mode 100644 index 00000000..2d865ce3 --- /dev/null +++ b/DISCARDED_STATE_MEMORY_ANALYSIS.md @@ -0,0 +1,132 @@ +# Discarded State Memory Savings Analysis + +## Current Implementation + +### Suspended State +- **Iframe**: Stays in DOM, hidden (`display: none`) +- **JavaScript Context**: Remains alive +- **Internal DOM**: Applet receives `suspend-dom` message - can remove from document but keep in memory (for quick restore ~10-50ms) +- **Memory Usage**: + - Iframe overhead: ~1-2 MB (base iframe + JavaScript context + framework code) + - Internal DOM in memory: ~2-10 MB (depending on applet complexity, if applet implements suspension) + - JavaScript heap (data stores, state): ~1-5 MB (depending on applet) + - **Total**: ~4-17 MB per applet + +### Discarded State +- **Iframe**: Stays in DOM, hidden (with additional CSS hiding) +- **JavaScript Context**: Remains alive (same as suspended) +- **Internal DOM**: Applet receives `discard-dom` message - should clear DOM and free memory +- **Memory Usage**: + - Iframe overhead: ~1-2 MB (base iframe + JavaScript context + framework code) - **same as suspended** + - Internal DOM: ~0 MB (cleared, if applet implements discard) + - JavaScript heap (data stores, state): ~1-5 MB (same as suspended - data stores remain) + - **Total**: ~2-7 MB per applet + +## Memory Savings Analysis + +### Per Applet Savings +- **Suspended**: ~4-17 MB +- **Discarded**: ~2-7 MB +- **Savings**: ~2-10 MB per applet (29-59% reduction) +- **Note**: Savings depend on applet implementing DOM clearing in `discard-dom` handler + +### With 10 Applets +- **All Suspended**: ~40-170 MB +- **All Discarded**: ~20-70 MB +- **Total Savings**: ~20-100 MB (50-59% reduction) + +### With 20 Applets +- **All Suspended**: ~80-340 MB +- **All Discarded**: ~40-140 MB +- **Total Savings**: ~40-200 MB (50-59% reduction) + +## Key Observations + +1. **Iframe Overhead is Fixed**: The iframe itself, JavaScript context, and framework code consume ~1-2 MB regardless of state. This cannot be reduced if we want background processing to continue. + +2. **DOM Memory is Variable**: The internal DOM memory depends on applet complexity: + - Simple applets: ~2-5 MB + - Complex applets (many DOM nodes, images, etc.): ~5-15 MB + - Very complex applets: ~10-20 MB+ + +3. **Data Stores Remain**: JavaScript heap (data stores, state) remains in both suspended and discarded states (~1-5 MB), as background processing needs access to data. + +4. **Actual Savings**: The discarded state saves the internal DOM memory (2-10 MB per applet), but: + - Iframe overhead (1-2 MB) remains + - Data stores (1-5 MB) remain + - **Net savings: 2-10 MB per applet (29-59% reduction)** + +5. **Implementation Dependency**: Savings only occur if applets implement DOM clearing in their `discard-dom` handler. If applets don't implement this, there's no difference between suspended and discarded. + +## Is the Discarded State Worth It? + +### Arguments FOR keeping discarded state: +- **Moderate savings for complex applets**: 5-10 MB per applet can add up +- **Scales with number of applets**: With 10+ applets, savings can be 50-100 MB +- **Low cost**: Restore time is only slightly slower (~50-200ms vs ~10-50ms) +- **Memory pressure scenarios**: Useful when system is low on memory +- **Progressive optimization**: Allows gradual memory recovery (inactive → suspended → discarded) + +### Arguments AGAINST discarded state: +- **Minimal savings for simple applets**: Only 2-5 MB per applet +- **Iframe overhead remains**: 1-2 MB per applet still consumed +- **Data stores remain**: 1-5 MB per applet still consumed +- **Additional complexity**: More states to manage and test +- **Restore time difference**: 50-200ms vs 10-50ms (still fast, but slower) +- **Implementation dependency**: Requires applets to implement DOM clearing for savings to occur +- **Diminishing returns**: Most savings come from suspended state (clearing DOM), discarded adds incremental benefit + +## Recommendation + +The discarded state provides **meaningful memory savings** (2-10 MB per applet, 20-200 MB total with many applets), especially for: +- Complex applets with large DOM trees +- Systems with many applets (10+) +- Memory-constrained environments + +However, the savings are **moderate** because: +- The iframe overhead (1-2 MB) cannot be eliminated if background processing must continue +- Simple applets see minimal benefit (2-5 MB savings) +- The complexity cost may not be worth it for simple use cases + +## Alternative: Simplified Two-State Approach + +If the discarded state adds too much complexity for minimal benefit, consider: + +1. **`active`**: Full rendering +2. **`inactive`**: DOM hidden, background processing continues +3. **`suspended`**: Internal DOM cleared (combines current suspended + discarded) + +This would: +- Reduce complexity (one less state) +- Still provide memory savings (clear DOM when suspended) +- Slightly slower restore (~50-200ms) but still acceptable +- Simpler to understand and maintain + +## Conclusion + +The discarded state provides **moderate memory savings** (2-10 MB per applet, 29-59% reduction), especially valuable when: +- You have many applets (10+) +- Applets are complex (large DOM trees) +- System memory is constrained +- Applets implement DOM clearing in their `discard-dom` handler + +**However**, the savings are **incremental** over the suspended state: +- **Suspended state** already provides the majority of memory savings by clearing DOM +- **Discarded state** adds only incremental benefit (same DOM clearing, just happens later) +- The main difference is **timing** (suspended after 5 min, discarded after 30 min) + +### Recommendation + +**Option 1: Keep discarded state** (current implementation) +- Provides progressive memory optimization +- Useful for memory-constrained systems +- Adds complexity but manageable + +**Option 2: Simplify to three states** (active/inactive/suspended) +- Have `suspended` clear DOM immediately (combine current suspended + discarded behavior) +- Simpler to understand and maintain +- Still provides 29-59% memory savings +- Slightly faster restore time (~10-50ms) since DOM is already cleared + +**Verdict**: The discarded state provides **moderate incremental value** (2-10 MB per applet). If complexity is a concern, simplifying to three states with immediate DOM clearing in `suspended` would provide similar savings with less complexity. + diff --git a/PERFORMANCE_MEASUREMENT_GUIDE.md b/PERFORMANCE_MEASUREMENT_GUIDE.md new file mode 100644 index 00000000..f89a3a74 --- /dev/null +++ b/PERFORMANCE_MEASUREMENT_GUIDE.md @@ -0,0 +1,501 @@ +# Performance Measurement Guide for Tool Setup Time + +This guide explains how to measure the setup time for tools (applets) when a group is loaded, and how to track performance improvements from optimizations. + +## What to Measure + +When a group is loaded, tools need to be set up and initialized. Key metrics to track: + +1. **Time to Load Running Applets** - How long until all running applets are detected and loaded +2. **Time to Initialize Applet Stores** - How long until applet stores are ready +3. **Time to First Applet Ready** - How long until the first applet is ready to use +4. **Time to All Applets Ready** - How long until all applets are fully initialized +5. **Main Thread Blocking Time** - How much the setup process blocks the UI +6. **Iframe Creation Time** - How long it takes to create applet iframes +7. **Background Processor Setup Time** - How long to set up background processors (if implemented) + +--- + +## Method 1: Browser DevTools Performance Profiler + +### Steps: + +1. **Open DevTools** (F12 or Cmd+Option+I) +2. **Go to Performance tab** +3. **Click Record** (circle icon) +4. **Navigate to a group** (or refresh the page) +5. **Wait for all tools to be set up** +6. **Stop recording** + +### What to Look For: + +- **Long Tasks** (red bars) - Tasks over 50ms that block the main thread +- **JavaScript execution time** - Time spent in `allMyRunningApplets`, `appletStores`, iframe creation +- **Network requests** - When applet assets are fetched +- **Frame rate** - Should stay at 60fps, drops indicate blocking +- **Memory allocation** - Memory spikes during applet loading + +### Before Optimizations: +- You'll see long tasks during applet store initialization +- Multiple sequential iframe creations blocking the main thread +- Memory spikes as all iframes load simultaneously +- Frame rate drops during setup + +### After Optimizations (Background Processing + 2.1b): +- Fewer long tasks (background processors load separately) +- Only one main view iframe created at a time +- Smoother frame rate during setup +- Lower memory usage (background processors vs full iframes) + +--- + +## Method 2: Custom Performance Markers (Recommended) + +Add performance markers to measure specific setup operations. This method is **already implemented** in the codebase. + +### Implementation Details: + +**In `group-store.ts`:** +- `GROUP_SETUP_START` marker is set in the `GroupStore` constructor +- `RUNNING_APPLETS_START` and `RUNNING_APPLETS_END` markers track the time to load running applets +- Measurements are logged to console with `[PERF]` prefix + +**In `moss-store.ts`:** +- Individual applet store initialization is tracked per applet +- Slow applet store init (>100ms) is logged with a warning + +**In `view-frame.ts` (where applet iframes are actually loaded):** +- Tracks when each applet iframe loads via the `@load` event +- Marks `FIRST_APPLET_READY` when the first applet iframe finishes loading +- Measures from `GROUP_SETUP_START` if available, otherwise falls back to navigation timing +- Uses a module-level `Set` to track which applets have been marked as ready + +### Key Code Locations: + +```typescript +// group-store.ts - Group setup and running applets tracking +constructor(...) { + performance.mark(PERF_MARKERS.GROUP_SETUP_START); + // ... +} + +allMyRunningApplets = manualReloadStore(async () => { + performance.mark(PERF_MARKERS.RUNNING_APPLETS_START); + // ... load applets ... + performance.mark(PERF_MARKERS.RUNNING_APPLETS_END); + performance.measure('running-applets-load', ...); + // Logs: [PERF] Running applets load: XXXms (N applets) +}); + +// moss-store.ts - Applet store initialization tracking +appletStores = new LazyHoloHashMap((appletHash: EntryHash) => + asyncReadable(async (set) => { + const appletStoreMarker = `applet-store-init-${encodeHashToBase64(appletHash)}`; + performance.mark(`${appletStoreMarker}-start`); + // ... create applet store ... + performance.mark(`${appletStoreMarker}-end`); + performance.measure(appletStoreMarker, ...); + // Logs slow stores: [PERF] Slow applet store init: XXX took YYYms + }), +); + +// view-frame.ts - Applet iframe load tracking +handleIframeLoad() { + this.loading = false; + + // Track when applet iframe is ready (for main applet views) + if (this.renderView.type === 'applet-view' && + this.renderView.view.type === 'main' && + this.iframeKind.type === 'applet') { + const appletId = encodeHashToBase64(this.iframeKind.appletHash); + if (!readyApplets.has(appletId)) { + readyApplets.add(appletId); + + // Check if this is the first applet ready + if (readyApplets.size === 1) { + performance.mark(PERF_MARKERS.FIRST_APPLET_READY); + // Measures and logs: [PERF] First applet ready: XXXms + } + } + } +} +``` + +### View Results: + +The measurements are automatically logged to the console with `[PERF]` prefix. You can also query them programmatically: + +```javascript +// In browser console - Get all performance measures +performance.getEntriesByType('measure').forEach(measure => { + console.log(`${measure.name}: ${measure.duration.toFixed(2)}ms`); +}); + +// Get summary of key metrics +const measures = performance.getEntriesByType('measure'); +const summary = { + 'running-applets-load': measures + .filter(m => m.name === 'running-applets-load') + .map(m => m.duration), + 'first-applet-ready': measures + .filter(m => m.name === 'first-applet-ready') + .map(m => m.duration), + 'applet-store-init': measures + .filter(m => m.name.startsWith('applet-store-init-')) + .map(m => m.duration), +}; +console.table(summary); + +// Get all applet store init times +const appletStoreMeasures = measures + .filter(m => m.name.startsWith('applet-store-init-')) + .map(m => ({ + appletHash: m.name.replace('applet-store-init-', ''), + duration: m.duration.toFixed(2) + 'ms', + })); +console.table(appletStoreMeasures); +``` + +### Console Output: + +When you navigate to a group, you'll see console output like: +``` +[PERF] Running applets load: 234.56ms (5 applets) +[PERF] First applet ready: 456.78ms +[PERF] Slow applet store init: uhCEk... took 234.56ms +``` + +**Note:** The `first-applet-ready` measurement is triggered when the first applet iframe's `@load` event fires in `view-frame.ts`, which is the actual point when the applet is ready to use. + +--- + +## Method 3: Performance Logger Utility + +Use the existing performance logger for consistent measurement: + +### Add to `group-store.ts`: + +```typescript +import { perfLogger } from '../utils/performance-logger.js'; + +// In GroupStore constructor: +constructor(...) { + perfLogger.start('group-setup', { + groupDnaHash: encodeHashToBase64(this.groupDnaHash), + }); + // ... existing constructor code ... +} + +// In allMyRunningApplets: +allMyRunningApplets = manualReloadStore(async () => { + perfLogger.start('running-applets-load', { + groupDnaHash: encodeHashToBase64(this.groupDnaHash), + }); + + const applets = await this.groupClient.getMyRunningApplets(); + + perfLogger.log('running-applets-load'); + console.log(`[PERF] Loaded ${applets.size} running applets`); + + return applets; +}); +``` + +### Add to `view-frame.ts` (if using performance logger): + +```typescript +import { perfLogger } from '../../utils/performance-logger.js'; + +// Track applet iframe creation and loading +async firstUpdated() { + if (this.mossStore.isAppletDev) { + this.appletDevPort = await this.mossStore.getAppletDevPort(this.iframeKind); + } + + // Track when iframe starts loading (for main applet views) + if ( + this.renderView.type === 'applet-view' && + this.renderView.view.type === 'main' && + this.iframeKind.type === 'applet' + ) { + const appletId = encodeHashToBase64(this.iframeKind.appletHash); + perfLogger.start(`applet-iframe-${appletId}`, { + appletHash: appletId, + }); + } +} + +handleIframeLoad() { + this.loading = false; + + // Track when applet iframe is ready + if ( + this.renderView.type === 'applet-view' && + this.renderView.view.type === 'main' && + this.iframeKind.type === 'applet' + ) { + const appletId = encodeHashToBase64(this.iframeKind.appletHash); + perfLogger.log(`applet-iframe-${appletId}`); + } +} +``` + +**Note:** The current implementation uses the Performance API markers (Method 2) rather than the performance logger. The logger can be added if you prefer that approach. + +### View Results: + +```javascript +// In browser console: +perfLogger.printSummary(); + +// Get specific metrics +const metrics = perfLogger.getMetrics(); +const setupMetrics = metrics.filter(m => + m.name.includes('group-setup') || + m.name.includes('running-applets') || + m.name.includes('applet-iframe') +); +console.table(setupMetrics); +``` + +--- + +## Method 4: Network Tab Analysis + +### Steps: + +1. **Open DevTools Network tab** +2. **Filter by "Fetch/XHR" and "Other"** +3. **Navigate to a group** +4. **Observe request patterns** + +### What to Look For: + +**Before Optimizations:** +- Many simultaneous requests for applet assets +- Requests blocking each other +- Long waterfall pattern +- All iframes loading assets at once + +**After Optimizations:** +- Background processors load assets separately +- Only one main view loading assets at a time +- Better request timing +- Reduced concurrent requests + +### Metrics: +- **Total requests** - Should be similar, but timing differs +- **Request duration** - Should be similar per request +- **Concurrent requests** - Should be lower (only one main view) +- **Waterfall pattern** - Should show sequential loading instead of parallel + +--- + +## Method 5: Iframe Count and Memory Tracking + +### Custom Measurement Utility: + +```typescript +// src/renderer/src/utils/tool-setup-metrics.ts +import { getAllIframes } from './utils.js'; +import { MossStore } from '../moss-store.js'; + +export class ToolSetupMetrics { + private snapshots: Array<{ + timestamp: number; + iframeCount: number; + appletIframeCount: number; + memoryUsage?: number; + appletsReady: number; + }> = []; + + snapshot(mossStore: MossStore, groupStore: GroupStore): void { + const allIframes = getAllIframes(); + const appletIframes = Object.keys(mossStore.iframeStore.appletIframes); + const runningApplets = groupStore.allMyRunningApplets.value; + + this.snapshots.push({ + timestamp: performance.now(), + iframeCount: allIframes.length, + appletIframeCount: appletIframes.length, + memoryUsage: (performance as any).memory?.usedJSHeapSize, + appletsReady: runningApplets?.status === 'complete' + ? runningApplets.value.size + : 0, + }); + } + + getSummary(): { + totalSetupTime: number; + avgIframeCount: number; + maxIframeCount: number; + memoryIncrease: number; + appletsReadyTime: number; + } { + if (this.snapshots.length < 2) { + throw new Error('Need at least 2 snapshots'); + } + + const first = this.snapshots[0]; + const last = this.snapshots[this.snapshots.length - 1]; + + return { + totalSetupTime: last.timestamp - first.timestamp, + avgIframeCount: this.snapshots.reduce((sum, s) => sum + s.iframeCount, 0) / this.snapshots.length, + maxIframeCount: Math.max(...this.snapshots.map(s => s.iframeCount)), + memoryIncrease: last.memoryUsage && first.memoryUsage + ? last.memoryUsage - first.memoryUsage + : 0, + appletsReadyTime: this.snapshots.find(s => s.appletsReady > 0)?.timestamp || 0, + }; + } + + printSummary(): void { + const summary = this.getSummary(); + console.table({ + 'Total Setup Time': `${summary.totalSetupTime.toFixed(2)}ms`, + 'Average Iframe Count': summary.avgIframeCount.toFixed(1), + 'Max Iframe Count': summary.maxIframeCount, + 'Memory Increase': summary.memoryIncrease + ? `${(summary.memoryIncrease / 1024 / 1024).toFixed(2)} MB` + : 'N/A', + 'Applets Ready Time': summary.appletsReadyTime + ? `${summary.appletsReadyTime.toFixed(2)}ms` + : 'N/A', + }); + } +} + +export const toolSetupMetrics = new ToolSetupMetrics(); +``` + +### Use in `group-container.ts`: + +```typescript +import { toolSetupMetrics } from '../../utils/tool-setup-metrics.js'; + +async firstUpdated() { + // Take initial snapshot + toolSetupMetrics.snapshot(this._mossStore, this._groupStore); + + // Take periodic snapshots + const snapshotInterval = setInterval(() => { + toolSetupMetrics.snapshot(this._mossStore, this._groupStore); + }, 100); // Every 100ms + + // Stop after 10 seconds + setTimeout(() => { + clearInterval(snapshotInterval); + toolSetupMetrics.printSummary(); + }, 10000); +} +``` + +--- + +## Recommended Measurement Workflow + +### Before Implementation: + +1. **Set up measurement code** (Method 2 or 3 recommended) +2. **Test with a group that has 5-10 running applets** +3. **Record baseline metrics:** + - Time to load running applets + - Time to initialize applet stores + - Time to first applet ready + - Time to all applets ready + - Number of iframes created + - Memory usage during setup + - Main thread blocking time +4. **Repeat 3-5 times** and average the results + +### After Implementation: + +1. **Keep the same measurement code** +2. **Test with the same group and same applets** +3. **Record new metrics** +4. **Compare results** + +### Expected Improvements (with Background Processing + 2.1b): + +- **70-85% reduction** in memory usage during setup +- **50-70% reduction** in CPU usage during setup +- **Faster time to first applet ready** (only one iframe to load) +- **Lower iframe count** (10 background processors + 1 main vs 10 full iframes) +- **Smoother frame rate** during setup (less blocking) +- **Better scalability** (setup time doesn't increase linearly with applet count) + +--- + +## Quick Test Script + +Add this to browser console for quick measurements: + +```javascript +// Measure tool setup time +const startTime = performance.now(); +const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (entry.entryType === 'measure') { + console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`); + } + } +}); +observer.observe({ entryTypes: ['measure'] }); + +// Track iframe count +let iframeCounts = []; +const iframeInterval = setInterval(() => { + const iframes = document.querySelectorAll('iframe').length; + iframeCounts.push({ + time: performance.now() - startTime, + count: iframes, + }); +}, 100); + +// Navigate to group, then after setup: +clearInterval(iframeInterval); +console.log('Iframe count over time:', iframeCounts); +console.log(`Total setup time: ${(performance.now() - startTime).toFixed(2)}ms`); + +// Get all performance measures +const measures = performance.getEntriesByType('measure'); +console.table(measures.map(m => ({ + name: m.name, + duration: `${m.duration.toFixed(2)}ms`, +}))); +``` + +--- + +## Tips + +1. **Test with realistic data** - Use groups with 5-20 running applets +2. **Clear cache between tests** - Use DevTools > Application > Clear storage +3. **Disable extensions** - They can affect measurements +4. **Test on same machine** - Network conditions affect results +5. **Use Chrome/Edge** - Best DevTools for performance profiling +6. **Test multiple times** - Average results for accuracy +7. **Test with different applet counts** - See how setup time scales + +--- + +## Success Criteria + +Tool setup optimization is successful if: + +- ✅ Setup time doesn't increase linearly with applet count +- ✅ Time to first applet ready is < 2 seconds (for 10 applets) +- ✅ No main thread blocking > 50ms during setup +- ✅ Frame rate stays at 60fps during setup +- ✅ Memory usage during setup is < 100 MB (for 10 applets) +- ✅ Background processors load without blocking main thread +- ✅ Only one main view iframe created at a time (after 2.1b) + +--- + +## Related Documents + +- [Performance Optimization Plan](./PERFORMANCE_OPTIMIZATION_PLAN.md) - Overall optimization strategy +- [Background Processing Proposal](./BACKGROUND_PROCESSING_PROPOSAL.md) - Background processor implementation +- [Background Processing Performance Analysis](./BACKGROUND_PROCESSING_PERFORMANCE_ANALYSIS.md) - Expected benefits diff --git a/PERFORMANCE_OPTIMIZATION_PLAN.md b/PERFORMANCE_OPTIMIZATION_PLAN.md new file mode 100644 index 00000000..6d5f205e --- /dev/null +++ b/PERFORMANCE_OPTIMIZATION_PLAN.md @@ -0,0 +1,536 @@ +# Performance Optimization Plan + +Based on analysis of the codebase, this document outlines a staged approach to address the top three performance bottlenecks. + +## Summary of Issues + +1. **Excessive Concurrent Polling Intervals** - Multiple timers running simultaneously +2. **Rendering All Applets Even When Hidden** - All running applet iframes are created/loaded even when not visible (CRITICAL for common case) +3. **Inefficient Data Transformations in Render Functions** - Repeated filter/map/sort chains +4. **Expensive Async Processing in Render Pipeline** - Heavy async work in StoreSubscribers (LOW VALUE - only affects rarely-used unjoined tools tab) + +--- + +## Stage 1: Quick Wins (High Impact, Low Complexity) +**Estimated Time: 2-3 days** +**Expected Impact: 30-40% performance improvement** + +### 1.1 Memoize Data Transformations in Render Functions (UNJOINED TOOLS - LOW VALUE) +**Priority: LOW** (Rarely used feature) +**Complexity: LOW** +**Files:** `src/renderer/src/groups/elements/group-home.ts` + +**Note:** This optimization only affects the "Unactivated Tools" tab which is rarely used. Low priority. + +**Changes:** +- Move `TimeAgo` instance creation outside render function (create once, reuse) +- Memoize filtered/sorted applet lists using proper cache key based on actual data +- Cache `ignoredApplets` lookup result + +**Cache Update Frequency:** +- The underlying `unjoinedApplets` store polls every **10 seconds** (currently) or **20 seconds** (after optimization 1.2) +- The store only updates when data actually changes (new applet added, removed, or metadata changed) +- When the store updates, the `StoreSubscriber` automatically re-runs, which will invalidate the cache +- **New applets will appear within 10-20 seconds** of being added to the network (same as current behavior) + +**Implementation:** +```typescript +// At class level +private _timeAgo = new TimeAgo('en-US'); +private _cachedFilteredApplets: any[] | null = null; +private _cachedAppletsKey: string | null = null; + +// In render function, check cache before processing +renderNewApplets() { + // ... existing code ... + if (this._unjoinedApplets.value.status !== 'complete') { + // ... handle pending/error states ... + } + + // Create cache key based on actual applet hashes + filter state + // This ensures cache invalidates when applets are added/removed, not just when count changes + const appletHashes = Array.from(this._unjoinedApplets.value.value.keys()) + .map(h => encodeHashToBase64(h)) + .sort() + .join(','); + const ignoredApplets = this.mossStore.persistedStore.ignoredApplets.value( + encodeHashToBase64(this._groupStore.groupDnaHash), + ); + const ignoredKey = ignoredApplets ? ignoredApplets.sort().join(',') : ''; + const cacheKey = `${appletHashes}-${this._showIgnoredApplets}-${this._recentlyJoined.length}-${ignoredKey}`; + + // Return cached result if key matches + if (this._cachedAppletsKey === cacheKey && this._cachedFilteredApplets) { + return this._cachedFilteredApplets; + } + + // Process and cache + const filteredApplets = this._unjoinedApplets.value.value + .filter(/* ... existing filters ... */) + .map(/* ... existing mapping ... */) + .filter(/* ... existing filters ... */) + .sort(/* ... existing sort ... */); + + this._cachedAppletsKey = cacheKey; + this._cachedFilteredApplets = filteredApplets; + + return filteredApplets; +} +``` + +**Impact:** +- Eliminates redundant processing on every render cycle +- Cache automatically invalidates when underlying data changes (via StoreSubscriber) +- **No change to update frequency** - new applets still appear within 10-20 seconds + +--- + +### 1.2 Increase Polling Intervals for Non-Critical Data +**Priority: HIGH** +**Complexity: LOW** +**Files:** `src/renderer/src/groups/group-store.ts` + +**Changes:** +- Increase `ASSET_RELATION_POLLING_PERIOD` from 10s to 30s +- Increase `NEW_APPLETS_POLLING_FREQUENCY` from 10s to 15s (modified from plan: 20s) +- Keep peer status update interval unchanged (5s in `group-area-sidebar.ts`) +- Remove or consolidate the 4 staggered profile refetch timeouts (keep only one at 40s) + +**Implementation:** +```typescript +// group-store.ts +const ASSET_RELATION_POLLING_PERIOD = 30000; // 30 seconds (was 10s) +export const NEW_APPLETS_POLLING_FREQUENCY = 15000; // 15 seconds (was 10s) + +// Remove lines 192-206, replace with single timeout: +setTimeout(async () => { + this.allAgents = await this.profilesStore.client.getAgentsWithProfile(true); +}, 40000); +``` + +**Impact:** Reduces background network activity by 50-70% + +--- + +### 1.3 Add Visibility-Based Polling +**Priority: MEDIUM** +**Complexity: LOW** +**Files:** `src/renderer/src/groups/group-store.ts`, `src/renderer/src/elements/_new_design/navigation/group-area-sidebar.ts` + +**Changes:** +- Pause polling when tab/window is not visible +- Resume polling when tab becomes visible + +**Implementation:** +```typescript +// In GroupStore constructor +this._isVisible = document.visibilityState === 'visible'; +document.addEventListener('visibilitychange', () => { + this._isVisible = document.visibilityState === 'visible'; +}); + +// In polling intervals, check visibility: +window.setInterval(async () => { + if (!this._isVisible) return; // Skip if tab not visible + // ... existing polling code ... +}, ASSET_RELATION_POLLING_PERIOD); +``` + +**Impact:** Eliminates unnecessary polling when user isn't viewing the app + +--- + +## Stage 2: Medium Complexity Optimizations (High Impact for Common Case) +**Estimated Time: 1-2 weeks** +**Expected Impact: Additional 40-50% improvement** + +### 2.1 Only Render Selected Applet (CRITICAL - Common Case) +**Priority: CRITICAL** +**Complexity: MEDIUM-HIGH** (requires Weave API changes) +**Files:** +- `src/renderer/src/groups/elements/applet-main-views.ts` +- `@theweave/api` (Weave API changes) +- Background processing infrastructure + +**Problem:** Currently renders ALL running applets with iframes, hiding unused ones with `display: none`. This means: +- All applet iframes are created and loaded even when not visible +- Each iframe loads its full content, consuming memory and CPU +- With 10+ applets in a group, this creates significant overhead + +**⚠️ Important Constraint:** +This optimization **cannot be implemented** without first adding support for background processing, because: +- Tools need to run background tasks (notifications, sync, etc.) even when not visible +- Destroying iframes when deselected would break these background processes +- We need a pattern for tools to register background processing elements + +**Required Changes:** + +#### 2.1a: Add Background Processing Support to Weave API +**Priority: CRITICAL (Prerequisite for 2.1b)** +**Complexity: HIGH** +**Files:** +- `libs/api/src/api.ts` (AppletServices class) +- `libs/api/src/types.ts` (Type definitions) +- `src/renderer/src/applets/applet-host.ts` (Background processor iframe management) +- `src/renderer/src/layout/views/view-frame.ts` (Background processor rendering) + +**Changes:** +- Extend `AppletServices` class to include optional `backgroundProcessor` function field +- Add new `RenderInfo` type: `'background-processor'` +- Implement background processor iframe creation and lifecycle management in Moss +- Provide limited WeaveClient API access to background processors (notifications, signals, zome calls) + +**See detailed proposal:** [Background Processing Proposal](./BACKGROUND_PROCESSING_PROPOSAL.md) + +**Impact:** +- Enables optimization 2.1b without breaking tool functionality +- Follows existing Weave API patterns (similar to how creatables/blockTypes work) +- Tools define background processing in the same place they define other services +- Background processors run in the same UI bundle, just in a separate iframe context + +--- + +#### 2.1b: Only Render Selected Applet (Requires 2.1a) +**Priority: CRITICAL** +**Complexity: MEDIUM** +**Files:** `src/renderer/src/groups/elements/applet-main-views.ts` + +**Changes:** +- Only render the currently selected applet's main view +- Keep background processing iframes running (from 2.1a) +- Lazy-load applet main view iframes when selected +- Unload/destroy main view iframes when applet is deselected +- Use conditional rendering instead of `display: none` + +**Implementation:** +```typescript +// In applet-main-views.ts +render() { + switch (this._runningGroupApplets.value.status) { + case 'complete': + const selectedAppletHash = this._dashboardState.value.appletHash; + if (!selectedAppletHash) { + return html``; // No applet selected + } + + // Only render the selected applet's main view + const appletHash = Array.from(this._runningGroupApplets.value.value).find( + (hash) => encodeHashToBase64(hash) === encodeHashToBase64(selectedAppletHash) + ); + + if (!appletHash) { + return html`Applet not found`; + } + + return html` + { + // ... existing refresh logic ... + }} + > + `; + } +} + +// Background processing iframes are managed separately and always running +``` + +**Impact:** +- **Massive improvement** for common case (selected tool) +- Reduces memory usage by 70-90% (only one main iframe loaded) +- Reduces CPU usage (only one main applet view running) +- Faster applet switching (no need to hide/show, just create/destroy) +- Scales better with many applets in group +- **Background processing continues uninterrupted** + +--- + +### 2.2 Move Expensive Async Processing to Computed Stores (UNJOINED TOOLS - LOW VALUE) +**Priority: LOW** (Rarely used feature) +**Complexity: MEDIUM** +**Files:** `src/renderer/src/groups/elements/group-home.ts`, `src/renderer/src/groups/group-store.ts` + +**Note:** This only affects the rarely-used "Unactivated Tools" tab. Low priority unless users report issues. + +**Changes:** +- Create a new computed store in `GroupStore` for enriched unjoined applets +- Move async processing (applet entry fetching, tool info fetching) out of render pipeline +- Cache results with proper invalidation strategy +- Only recompute when underlying data changes + +**Implementation:** +```typescript +// In GroupStore +enrichedUnjoinedApplets = pipe( + this.unjoinedApplets, + async (appletsAndKeys) => { + // Process in batches to avoid overwhelming the system + const batchSize = 5; + const entries = Array.from(appletsAndKeys.entries()); + const results = []; + + for (let i = 0; i < entries.length; i += batchSize) { + const batch = entries.slice(i, i + batchSize); + const batchResults = await Promise.all( + batch.map(async ([appletHash, [agentKey, timestamp, joinedMembers]]) => { + // ... existing processing logic ... + }) + ); + results.push(...batchResults); + } + return results; + } +); + +// In group-home.ts, replace _unjoinedApplets StoreSubscriber: +_enrichedUnjoinedApplets = new StoreSubscriber( + this, + () => this._groupStore.enrichedUnjoinedApplets, + () => [this._groupStore], +); +``` + +**Impact:** Prevents render blocking, processes data only when needed + +--- + +### 2.3 Implement Request-Based Polling Instead of Time-Based +**Priority: MEDIUM** +**Complexity: MEDIUM** +**Files:** `src/renderer/src/groups/group-store.ts` + +**Changes:** +- Replace fixed intervals with exponential backoff +- Poll more frequently after user actions, less when idle +- Use `requestIdleCallback` for non-critical polling + +**Implementation:** +```typescript +private _pollAssetRelations = () => { + if (this._isVisible) { + // ... existing polling logic ... + } + // Exponential backoff: 10s -> 20s -> 40s -> max 60s + const nextDelay = Math.min( + this._assetPollDelay * 2, + 60000 + ); + this._assetPollDelay = nextDelay; + setTimeout(this._pollAssetRelations, nextDelay); +}; + +// Reset to fast polling after user interaction +onUserInteraction() { + this._assetPollDelay = 10000; + this._pollAssetRelations(); +} +``` + +**Impact:** Reduces idle-time polling while maintaining responsiveness + +--- + +### 2.4 Consolidate Multiple StoreSubscribers for Running Applets +**Priority: MEDIUM** +**Complexity: MEDIUM** +**Files:** `src/renderer/src/groups/elements/applet-main-views.ts`, `src/renderer/src/elements/_new_design/navigation/group-area-sidebar.ts` + +**Changes:** +- Combine related StoreSubscribers for running applets into single computed stores +- Reduce number of reactive subscriptions that trigger re-renders +- Optimize sidebar applet rendering + +**Implementation:** +```typescript +// In applet-main-views.ts, combine dashboard state and applets: +_appletViewData = new StoreSubscriber( + this, + () => joinAsync([ + this._groupStore.allMyRunningApplets, + this._mossStore.dashboardState(), + ]), + () => [this._groupStore, this._mossStore], +); +``` + +**Impact:** Reduces subscription overhead and re-render frequency for common case + +--- + +## Stage 3: Advanced Optimizations (Medium-High Impact) +**Estimated Time: 2-3 weeks** +**Expected Impact: Additional 20-30% improvement + better scalability** + +### 3.1 Implement Proper Reactive Subscriptions (Replace Polling) +**Priority: HIGH** +**Complexity: HIGH** +**Files:** Multiple files across the codebase + +**Changes:** +- Replace polling with event-driven updates where possible +- Use Holochain signals for real-time updates +- Implement proper cache invalidation strategies +- Only poll as fallback when signals unavailable + +**Implementation:** +```typescript +// Listen to signals instead of polling +this.groupClient.onSignal((signal) => { + if (signal.type === 'AppletRegistered') { + // Invalidate and refresh unjoined applets + this.unjoinedApplets.reload(); + } + if (signal.type === 'AssetRelationUpdated') { + // Update specific asset store instead of polling all + this._assetStores[signal.wal]?.store.reload(); + } +}); +``` + +**Impact:** Eliminates most polling, real-time updates, better scalability + +--- + +### 3.2 Optimize Asset Relation Polling for Selected Tools +**Priority: MEDIUM** +**Complexity: MEDIUM** +**Files:** `src/renderer/src/groups/group-store.ts` + +**Changes:** +- Only poll asset relations for assets currently visible/selected +- Pause polling for assets in hidden iframes +- Resume when asset becomes visible + +**Impact:** Reduces network activity when multiple tools are installed but only one is active + +--- + +### 3.3 Add Request Deduplication and Batching +**Priority: MEDIUM** +**Complexity: MEDIUM** +**Files:** `src/renderer/src/groups/group-store.ts`, `src/renderer/src/moss-store.ts` + +**Changes:** +- Deduplicate concurrent requests for same data +- Batch multiple small requests into single calls +- Implement request queue with priority + +**Implementation:** +```typescript +class RequestBatcher { + private pending = new Map>(); + + async batch(key: string, fn: () => Promise): Promise { + if (this.pending.has(key)) { + return this.pending.get(key); + } + const promise = fn().finally(() => this.pending.delete(key)); + this.pending.set(key, promise); + return promise; + } +} +``` + +**Impact:** Reduces redundant network calls, improves efficiency + +--- + +## Stage 4: Long-Term Architectural Improvements +**Estimated Time: 1-2 months** +**Expected Impact: Better maintainability + 10-20% additional improvement** + +### 4.1 Refactor Store Architecture +**Priority: LOW-MEDIUM** +**Complexity: HIGH** +**Files:** All store-related files + +**Changes:** +- Implement unified store pattern with proper caching layers +- Add store-level memoization and invalidation +- Create store composition utilities + +**Impact:** Better code maintainability, easier to optimize in future + +--- + +### 4.2 Implement Service Worker for Background Processing +**Priority: LOW** +**Complexity: HIGH** +**Files:** New service worker files + +**Changes:** +- Move heavy data processing to service worker +- Cache responses more aggressively +- Prefetch likely-needed data + +**Impact:** Offloads main thread, better background performance + +--- + +## Recommended Implementation Order + +### Phase 1 (Week 1): Quick Wins +1. ✅ 1.1 - Memoize Data Transformations (1 day) +2. ✅ 1.2 - Increase Polling Intervals (0.5 day) +3. ✅ 1.3 - Visibility-Based Polling (1 day) + +**Total: ~2.5 days, immediate 30-40% improvement** + +### Phase 2 (Weeks 2-3): Medium Optimizations (Common Case Focus) +1. ✅ 2.1a - Add Background Processing Support to Weave API (CRITICAL PREREQUISITE - 1-2 weeks) +2. ✅ 2.1b - Only Render Selected Applet (CRITICAL - 2-3 days, requires 2.1a) +3. ✅ 2.2 - Move Async Processing to Stores (LOW VALUE - skip or defer) +4. ✅ 2.3 - Request-Based Polling (2-3 days) +5. ✅ 2.4 - Consolidate StoreSubscribers (2 days) + +**Total: ~2-3 weeks (including Weave API work), additional 50-70% improvement for common case** + +**Note:** 2.1a requires coordination with Weave API team and may need to be done in parallel or before other Phase 2 work. + +### Phase 3 (Weeks 4-6): Advanced Optimizations +1. ✅ 3.1 - Reactive Subscriptions (1-2 weeks) +2. ✅ 3.2 - Optimize Asset Relation Polling (3-5 days) +3. ✅ 3.3 - Request Deduplication (2-3 days) + +**Total: ~2-3 weeks, additional 20-30% improvement** + +### Phase 4 (Months 2-3): Long-Term +- 4.1 - Store Architecture Refactor +- 4.2 - Service Worker Implementation + +--- + +## Success Metrics + +Track these metrics before and after each phase: + +1. **Network Requests per Minute**: Should decrease by 50-70% after Phase 1-2 +2. **Time to Interactive**: Should improve by 30-50% after Phase 1-2 +3. **CPU Usage (Idle)**: Should decrease by 40-60% after Phase 1-2 +4. **Memory Usage**: Should stabilize after Phase 2 +5. **Render Time**: Should decrease by 50-70% after Phase 1-2 +6. **User-Reported Lag**: Should be eliminated after Phase 2 + +--- + +## Risk Mitigation + +1. **Feature Flags**: Implement each optimization behind a feature flag +2. **Gradual Rollout**: Test each phase with small user group first +3. **Monitoring**: Add performance monitoring before starting +4. **Rollback Plan**: Keep ability to revert each change independently + +--- + +## Notes + +- Start with Phase 1 for immediate impact with minimal risk +- Phase 2 provides the biggest architectural improvements +- Phase 3 requires more testing but provides best long-term scalability +- Phase 4 is optional and can be done incrementally + +Each phase builds on the previous, so order matters. Don't skip phases, but you can delay Phase 4 if needed. + diff --git a/example/ui/src/example-applet.ts b/example/ui/src/example-applet.ts index 2adb44de..091ca8b3 100644 --- a/example/ui/src/example-applet.ts +++ b/example/ui/src/example-applet.ts @@ -9,7 +9,7 @@ import './elements/create-post.js'; import './elements/post-detail.js'; import './elements/posts-context.js'; -import { WeaveClient, FrameNotification, UnsubscribeFunction } from '@theweave/api'; +import { WeaveClient, FrameNotification, UnsubscribeFunction, LifecycleState } from '@theweave/api'; import { weaveClientContext } from '@theweave/elements'; import '@theweave/elements/dist/elements/weave-client-context.js'; @@ -39,6 +39,8 @@ export class ExampleApplet extends LitElement { onBeforeUnloadUnsubscribe: UnsubscribeFunction | undefined; + lifecycleUnsubscribe: UnsubscribeFunction | undefined; + firstUpdated() { this.onBeforeUnloadUnsubscribe = this.weaveClient.onBeforeUnload(async () => { // Uncomment below to test that unloading after force reload timeout works @@ -46,6 +48,33 @@ export class ExampleApplet extends LitElement { // await new Promise((resolve) => setTimeout(resolve, 10000)); console.log('Unloading now.'); }); + + // Subscribe to lifecycle changes + this.lifecycleUnsubscribe = this.weaveClient.onLifecycleChange((state: LifecycleState) => { + console.log(`[Example Applet] Lifecycle state changed to: ${state}`); + + // Tools can adjust their behavior based on lifecycle state + // For example, pause/resume timers, cleanup DOM, etc. + switch (state) { + case 'active': + // Applet is active - resume full functionality + console.log('[Example Applet] Resuming full functionality'); + break; + case 'inactive': + // Applet is inactive but recently used - continue background processing + console.log('[Example Applet] Continuing background processing'); + break; + case 'suspended': + // Applet is suspended - DOM removed but data kept + console.log('[Example Applet] Suspended - DOM removed, data preserved'); + break; + case 'discarded': + // Applet is discarded - iframe will be recreated on activation + console.log('[Example Applet] Discarded - will be recreated on activation'); + break; + } + }); + // To test whether applet iframe properly gets removed after disabling applet. // setInterval(() => { // console.log('Hello from the example applet iframe.'); @@ -81,22 +110,22 @@ export class ExampleApplet extends LitElement { .peerStatusStore=${this.weaveClient.renderInfo.peerStatusStore} @notification=${(e: CustomEvent) => this.notifyWe(e.detail)} @post-selected=${async (e: CustomEvent) => { - const appInfo = await client.appInfo(); - if (!appInfo) throw new Error('AppInfo is null.'); - const dnaHash = (appInfo.cell_info.forum[0].value as ProvisionedCell) - .cell_id[0]; - this.weaveClient!.openAsset({ hrl: [dnaHash, e.detail.postHash] }, 'side'); - }} + const appInfo = await client.appInfo(); + if (!appInfo) throw new Error('AppInfo is null.'); + const dnaHash = (appInfo.cell_info.forum[0].value as ProvisionedCell) + .cell_id[0]; + this.weaveClient!.openAsset({ hrl: [dnaHash, e.detail.postHash] }, 'side'); + }} @drag-post=${async (e: CustomEvent) => { - console.log('GOT DRAG POST EVENT!'); - const appInfo = await client.appInfo(); - if (!appInfo) throw new Error('AppInfo is null.'); - const dnaHash = (appInfo.cell_info.forum[0].value as ProvisionedCell) - .cell_id[0]; - this.weaveClient!.assets.dragAsset({ - hrl: [dnaHash, e.detail], - }); - }} + console.log('GOT DRAG POST EVENT!'); + const appInfo = await client.appInfo(); + if (!appInfo) throw new Error('AppInfo is null.'); + const dnaHash = (appInfo.cell_info.forum[0].value as ProvisionedCell) + .cell_id[0]; + this.weaveClient!.assets.dragAsset({ + hrl: [dnaHash, e.detail], + }); + }} > @@ -155,27 +184,27 @@ export class ExampleApplet extends LitElement { diff --git a/iframes/applet-iframe/src/index.ts b/iframes/applet-iframe/src/index.ts index b182fa4c..4a0a3f21 100644 --- a/iframes/applet-iframe/src/index.ts +++ b/iframes/applet-iframe/src/index.ts @@ -470,6 +470,7 @@ const weaveApi: WeaveServices = { peerStatusStore, appletHash, groupProfiles: iframeConfig.groupProfiles, + lifecycleState: 'active', // Initial state, will be updated by parent }; window.addEventListener('weave-client-connected', async () => { @@ -521,6 +522,49 @@ const weaveApi: WeaveServices = { throw new Error('Bad RenderView type.'); } window.dispatchEvent(new CustomEvent('applet-iframe-ready')); + + // Listen for lifecycle state changes from parent + window.addEventListener('message', (event: MessageEvent) => { + // Only handle messages from parent window + if (event.source !== window.parent) return; + + const message = event.data; + if (!message || typeof message !== 'object') return; + + switch (message.type) { + case 'lifecycle-state-change': { + const { state, previousState } = message; + + // Update renderInfo lifecycle state + if (window.__WEAVE_RENDER_INFO__ && window.__WEAVE_RENDER_INFO__.type === 'applet-view') { + window.__WEAVE_RENDER_INFO__.lifecycleState = state; + } + + // Dispatch event for tools to listen to + window.dispatchEvent( + new CustomEvent('weave-lifecycle-change', { + detail: { state, previousState }, + }), + ); + break; + } + case 'suspend-dom': { + // Tool can implement DOM suspension logic + window.dispatchEvent(new CustomEvent('weave-suspend-dom')); + break; + } + case 'restore-dom': { + // Tool can implement DOM restoration logic + window.dispatchEvent(new CustomEvent('weave-restore-dom')); + break; + } + case 'discard-dom': { + // Tool can implement DOM discard logic (save state, etc.) + window.dispatchEvent(new CustomEvent('weave-discard-dom')); + break; + } + } + }); })(); // async function fetchLocalStorage() { diff --git a/libs/api/src/api.ts b/libs/api/src/api.ts index d9b43ea4..595572e9 100644 --- a/libs/api/src/api.ts +++ b/libs/api/src/api.ts @@ -30,6 +30,7 @@ import { UnsubscribeFunction, GroupPermissionType, AssetStore, + LifecycleState, } from './types'; import { postMessage } from './utils.js'; import { decode, encode } from '@msgpack/msgpack'; @@ -82,8 +83,7 @@ export function weaveUrlFromWal(wal: WAL, webPrefix = false) { } url = url + - `weave-${window.__WEAVE_PROTOCOL_VERSION__ || '0.12'}://hrl/${encodeHashToBase64(wal.hrl[0])}/${encodeHashToBase64(wal.hrl[1])}${ - wal.context ? `?context=${encodeContext(wal.context)}` : '' + `weave-${window.__WEAVE_PROTOCOL_VERSION__ || '0.12'}://hrl/${encodeHashToBase64(wal.hrl[0])}/${encodeHashToBase64(wal.hrl[1])}${wal.context ? `?context=${encodeContext(wal.context)}` : '' }`; return url; } @@ -458,7 +458,42 @@ export class WeaveClient implements WeaveServices { return window.__WEAVE_RENDER_INFO__; } - private constructor() {} + /** + * Get the current lifecycle state of this applet iframe. + * Returns 'active' for non-applet views or if lifecycle state is not available. + */ + get lifecycleState(): LifecycleState { + const renderInfo = this.renderInfo; + if (renderInfo.type === 'applet-view') { + return renderInfo.lifecycleState; + } + return 'active'; + } + + /** + * Subscribe to lifecycle state changes. + * Allows tools to respond to state changes (e.g., pause/resume timers, cleanup DOM, etc.) + */ + onLifecycleChange( + callback: (state: LifecycleState) => void, + ): UnsubscribeFunction { + // Listen for lifecycle change events from parent + const handleLifecycleChange = (event: CustomEvent<{ state: LifecycleState; previousState: LifecycleState }>) => { + callback(event.detail.state); + }; + + window.addEventListener('weave-lifecycle-change', handleLifecycleChange as EventListener); + + // Return initial state + callback(this.lifecycleState); + + // Return unsubscribe function + return () => { + window.removeEventListener('weave-lifecycle-change', handleLifecycleChange as EventListener); + }; + } + + private constructor() { } static async connect(appletServices?: AppletServices): Promise { if (window.__WEAVE_RENDER_INFO__) { diff --git a/libs/api/src/types.ts b/libs/api/src/types.ts index 04344d34..b77ac6ca 100644 --- a/libs/api/src/types.ts +++ b/libs/api/src/types.ts @@ -38,22 +38,22 @@ export type WeaveUrl = string; export type WeaveLocation = | { - type: 'group'; - dnaHash: DnaHash; - } + type: 'group'; + dnaHash: DnaHash; + } | { - type: 'applet'; - appletHash: AppletHash; - } + type: 'applet'; + appletHash: AppletHash; + } | { - type: 'asset'; - wal: WAL; - } + type: 'asset'; + wal: WAL; + } | { - type: 'invitation'; - // network seed and membrane proofs - secret: string; - }; + type: 'invitation'; + // network seed and membrane proofs + secret: string; + }; // Weave Asset Locator - an HRL with context // Use case: Image we want to point to a specific section of a document @@ -168,44 +168,44 @@ export type AppletView = | { type: 'main' } | { type: 'block'; block: string; context: any } | { - type: 'asset'; - /** - * If the WAL points to a Record (AnyDhtHash) recordInfo will be defined, if the WAL - * points to a DNA (i.e. null hash for the AnyDhtHash) then recordInfo is not defined - */ - recordInfo?: RecordInfo; - wal: WAL; - } - | { - type: 'creatable'; - name: CreatableName; - /** - * To be called after the creatable has been successfully created. Will close the creatable view. - * @param wal - * @returns - */ - resolve: (wal: WAL) => Promise; - /** - * To be called if creation fails due to an error - * @param error - * @returns - */ - reject: (error: any) => Promise; - /** - * To be called if user cancels the creation - */ - cancel: () => Promise; - }; + type: 'asset'; + /** + * If the WAL points to a Record (AnyDhtHash) recordInfo will be defined, if the WAL + * points to a DNA (i.e. null hash for the AnyDhtHash) then recordInfo is not defined + */ + recordInfo?: RecordInfo; + wal: WAL; + } + | { + type: 'creatable'; + name: CreatableName; + /** + * To be called after the creatable has been successfully created. Will close the creatable view. + * @param wal + * @returns + */ + resolve: (wal: WAL) => Promise; + /** + * To be called if creation fails due to an error + * @param error + * @returns + */ + reject: (error: any) => Promise; + /** + * To be called if user cancels the creation + */ + cancel: () => Promise; + }; export type CrossGroupView = | { - type: 'main'; - } + type: 'main'; + } | { - type: 'block'; - block: string; - context: any; - }; + type: 'block'; + block: string; + context: any; + }; export type CreatableType = { /** @@ -224,16 +224,16 @@ export type CreatableName = string; export type CreatableResult = | { - type: 'success'; - wal: WAL; - } + type: 'success'; + wal: WAL; + } | { - type: 'cancel'; - } + type: 'cancel'; + } | { - type: 'error'; - error: any; - }; + type: 'error'; + error: any; + }; export type BlockType = { label: string; @@ -243,83 +243,107 @@ export type BlockType = { export type BlockName = string; +export type LifecycleState = 'active' | 'inactive' | 'suspended' | 'discarded'; + export type RenderInfo = | { - type: 'applet-view'; - view: AppletView; - appletClient: AppClient; - profilesClient: ProfilesClient; - peerStatusStore: ReadonlyPeerStatusStore; - appletHash: AppletHash; - /** - * Non-exhaustive array of profiles of the groups the given applet is shared with. - * Note that an applet may be shared with other groups beyond the ones returned - * by this array if the applet has been federated with groups that the agent - * of the given Moss instance is not part of. - */ - groupProfiles: GroupProfile[]; - } - | { - type: 'cross-group-view'; - view: CrossGroupView; - applets: ReadonlyMap; - }; + type: 'applet-view'; + view: AppletView; + appletClient: AppClient; + profilesClient: ProfilesClient; + peerStatusStore: ReadonlyPeerStatusStore; + appletHash: AppletHash; + /** + * Non-exhaustive array of profiles of the groups the given applet is shared with. + * Note that an applet may be shared with other groups beyond the ones returned + * by this array if the applet has been federated with groups that the agent + * of the given Moss instance is not part of. + */ + groupProfiles: GroupProfile[]; + /** + * Current lifecycle state of this applet iframe. + * - 'active': Applet is currently visible and selected in the active group + * - 'inactive': Applet is not visible but recently was active (DOM hidden) + * - 'suspended': Applet has been inactive for a while (DOM removed but kept in memory) + * - 'discarded': Applet has been suspended for a long time (iframe removed, recreate on activate) + */ + lifecycleState: LifecycleState; + } + | { + type: 'cross-group-view'; + view: CrossGroupView; + applets: ReadonlyMap; + }; export type RenderView = | { - type: 'applet-view'; - view: AppletView; - } + type: 'applet-view'; + view: AppletView; + } | { - type: 'cross-group-view'; - view: CrossGroupView; - }; + type: 'cross-group-view'; + view: CrossGroupView; + }; export type ParentToAppletMessage = | { - type: 'get-applet-asset-info'; - wal: WAL; - recordInfo?: RecordInfo; - } + type: 'get-applet-asset-info'; + wal: WAL; + recordInfo?: RecordInfo; + } + | { + type: 'get-block-types'; + } + | { + type: 'search'; + filter: string; + } + | { + type: 'peer-status-update'; + payload: PeerStatusUpdate; + } + | { + type: 'on-before-unload'; + } | { - type: 'get-block-types'; - } + type: 'asset-store-update'; + /** + * We can save ourselves one unnecessary stringification step + * by sending it as stringified + */ + walStringified: string; + value: AsyncStatus; + } | { - type: 'search'; - filter: string; - } + type: 'remote-signal-received'; + payload: Uint8Array; + } | { - type: 'peer-status-update'; - payload: PeerStatusUpdate; - } + type: 'lifecycle-state-change'; + state: LifecycleState; + previousState: LifecycleState; + } | { - type: 'on-before-unload'; - } + type: 'suspend-dom'; + } | { - type: 'asset-store-update'; - /** - * We can save ourselves one unnecessary stringification step - * by sending it as stringified - */ - walStringified: string; - value: AsyncStatus; - } + type: 'restore-dom'; + } | { - type: 'remote-signal-received'; - payload: Uint8Array; - }; + type: 'discard-dom'; + }; export type IframeKind = | { - type: 'applet'; - appletHash: AppletHash; // Only required in dev mode when iframe origin is localhost - subType: string; - } + type: 'applet'; + appletHash: AppletHash; // Only required in dev mode when iframe origin is localhost + subType: string; + } | { - type: 'cross-group'; - toolCompatibilityId: string; // Only required in dev mode when iframe origin is localhost - subType: string; - }; + type: 'cross-group'; + toolCompatibilityId: string; // Only required in dev mode when iframe origin is localhost + subType: string; + }; export type AppletToParentMessage = { request: AppletToParentRequest; @@ -334,226 +358,226 @@ export type ZomeCallLogInfo = { export type AppletToParentRequest = | { - type: 'ready'; - } - | { - // This one is used by initializeHotReload() and is the only one that - // affects the API exposed to tool devs - // - // It's also used as a means to register the iframe in order for Moss - // to be able to send messages to it - type: 'get-iframe-config'; - id: string; - subType: 'main' | 'asset' | 'block' | 'creatable'; - } - | { - type: 'unregister-iframe'; - id: string; - } - | { - type: 'get-record-info'; - hrl: Hrl; - } - | { - type: 'sign-zome-call'; - request: CallZomeRequest; - } - | { - type: 'log-zome-call'; - info: ZomeCallLogInfo; - } - | { - type: 'open-view'; - request: OpenViewRequest; - } - | { - type: 'search'; - filter: string; - } - | { - type: 'notify-frame'; - notifications: Array; - } - | { - type: 'get-applet-info'; - appletHash: AppletHash; - } - | { - type: 'get-group-profile'; - groupHash: DnaHash; - } - | { - type: 'my-group-permission-type'; - } - | { - type: 'applet-participants'; - } - | { - type: 'user-select-screen'; - } - | { - type: 'toggle-pocket'; - } - | { - type: 'update-creatable-types'; - value: Record; - } - | { - type: 'creatable-result'; - result: CreatableResult; - /** - * The id of the dialog this result is coming from - */ - dialogId: string; - } - | { - type: 'get-applet-iframe-script'; - } - | { - type: 'request-close'; - } - | { - type: 'send-remote-signal'; - payload: Uint8Array; - toAgents?: AgentPubKey[]; - } - | { - type: 'create-clone-cell'; - req: CreateCloneCellRequest; - publicToGroupMembers: boolean; - } - | { - type: 'disable-clone-cell'; - req: DisableCloneCellRequest; - } - | { - type: 'enable-clone-cell'; - req: EnableCloneCellRequest; - } + type: 'ready'; + } + | { + // This one is used by initializeHotReload() and is the only one that + // affects the API exposed to tool devs + // + // It's also used as a means to register the iframe in order for Moss + // to be able to send messages to it + type: 'get-iframe-config'; + id: string; + subType: 'main' | 'asset' | 'block' | 'creatable'; + } + | { + type: 'unregister-iframe'; + id: string; + } + | { + type: 'get-record-info'; + hrl: Hrl; + } + | { + type: 'sign-zome-call'; + request: CallZomeRequest; + } + | { + type: 'log-zome-call'; + info: ZomeCallLogInfo; + } + | { + type: 'open-view'; + request: OpenViewRequest; + } + | { + type: 'search'; + filter: string; + } + | { + type: 'notify-frame'; + notifications: Array; + } + | { + type: 'get-applet-info'; + appletHash: AppletHash; + } + | { + type: 'get-group-profile'; + groupHash: DnaHash; + } + | { + type: 'my-group-permission-type'; + } + | { + type: 'applet-participants'; + } + | { + type: 'user-select-screen'; + } + | { + type: 'toggle-pocket'; + } + | { + type: 'update-creatable-types'; + value: Record; + } + | { + type: 'creatable-result'; + result: CreatableResult; + /** + * The id of the dialog this result is coming from + */ + dialogId: string; + } + | { + type: 'get-applet-iframe-script'; + } + | { + type: 'request-close'; + } + | { + type: 'send-remote-signal'; + payload: Uint8Array; + toAgents?: AgentPubKey[]; + } + | { + type: 'create-clone-cell'; + req: CreateCloneCellRequest; + publicToGroupMembers: boolean; + } + | { + type: 'disable-clone-cell'; + req: DisableCloneCellRequest; + } + | { + type: 'enable-clone-cell'; + req: EnableCloneCellRequest; + } /** * Asset related requests */ | { - type: 'asset-to-pocket'; - wal: WAL; - } + type: 'asset-to-pocket'; + wal: WAL; + } | { - type: 'user-select-asset'; - from?: 'search' | 'pocket' | 'create'; - } + type: 'user-select-asset'; + from?: 'search' | 'pocket' | 'create'; + } | { - type: 'user-select-asset-relation-tag'; - } + type: 'user-select-asset-relation-tag'; + } | { - type: 'get-global-asset-info'; - wal: WAL; - } + type: 'get-global-asset-info'; + wal: WAL; + } | { - type: 'drag-asset'; - wal: WAL; - } + type: 'drag-asset'; + wal: WAL; + } | { - type: 'add-tags-to-asset'; - wal: WAL; - tags: string[]; - } + type: 'add-tags-to-asset'; + wal: WAL; + tags: string[]; + } | { - type: 'remove-tags-from-asset'; - wal: WAL; - tags: string[]; - } + type: 'remove-tags-from-asset'; + wal: WAL; + tags: string[]; + } | { - type: 'add-asset-relation'; - srcWal: WAL; - dstWal: WAL; - tags?: string[]; - } + type: 'add-asset-relation'; + srcWal: WAL; + dstWal: WAL; + tags?: string[]; + } | { - type: 'remove-asset-relation'; - relationHash: EntryHash; - } + type: 'remove-asset-relation'; + relationHash: EntryHash; + } | { - type: 'add-tags-to-asset-relation'; - relationHash: EntryHash; - tags: string[]; - } + type: 'add-tags-to-asset-relation'; + relationHash: EntryHash; + tags: string[]; + } | { - type: 'remove-tags-from-asset-relation'; - relationHash: EntryHash; - tags: string[]; - } + type: 'remove-tags-from-asset-relation'; + relationHash: EntryHash; + tags: string[]; + } | { - type: 'get-all-asset-relation-tags'; - crossGroup?: boolean; - } + type: 'get-all-asset-relation-tags'; + crossGroup?: boolean; + } | { - type: 'subscribe-to-asset-store'; - wal: WAL; - } + type: 'subscribe-to-asset-store'; + wal: WAL; + } | { - type: 'unsubscribe-from-asset-store'; - wal: WAL; - }; + type: 'unsubscribe-from-asset-store'; + wal: WAL; + }; export type OpenViewRequest = | { - type: 'applet-main'; - appletHash: EntryHash; - } + type: 'applet-main'; + appletHash: EntryHash; + } | { - type: 'cross-group-main'; - appletBundleId: string; - } + type: 'cross-group-main'; + appletBundleId: string; + } | { - type: 'applet-block'; - appletHash: EntryHash; - block: string; - context: any; - } + type: 'applet-block'; + appletHash: EntryHash; + block: string; + context: any; + } | { - type: 'cross-group-block'; - appletBundleId: string; - block: string; - context: any; - } + type: 'cross-group-block'; + appletBundleId: string; + block: string; + context: any; + } | { - type: 'asset'; - wal: WAL; - mode?: OpenAssetMode; - }; + type: 'asset'; + wal: WAL; + mode?: OpenAssetMode; + }; export type IframeConfig = | { - type: 'applet'; - appPort: number; - /** - * The origin of the main Moss UI. Used to validate iframe message origins. - */ - mainUiOrigin: string; - appletHash: EntryHash; - authenticationToken: AppAuthenticationToken; - weaveProtocolVersion: string; - mossVersion: string; - profilesLocation: ProfilesLocation; - groupProfiles: GroupProfile[]; - zomeCallLogging: boolean; - } - | { - type: 'cross-group'; - appPort: number; - /** - * The origin of the main Moss UI. Used to validate iframe message origins. - */ - mainUiOrigin: string; - weaveProtocolVersion: string; - mossVersion: string; - applets: Record; - zomeCallLogging: boolean; - } - | { - type: 'not-installed'; - appletName: string; - }; + type: 'applet'; + appPort: number; + /** + * The origin of the main Moss UI. Used to validate iframe message origins. + */ + mainUiOrigin: string; + appletHash: EntryHash; + authenticationToken: AppAuthenticationToken; + weaveProtocolVersion: string; + mossVersion: string; + profilesLocation: ProfilesLocation; + groupProfiles: GroupProfile[]; + zomeCallLogging: boolean; + } + | { + type: 'cross-group'; + appPort: number; + /** + * The origin of the main Moss UI. Used to validate iframe message origins. + */ + mainUiOrigin: string; + weaveProtocolVersion: string; + mossVersion: string; + applets: Record; + zomeCallLogging: boolean; + } + | { + type: 'not-installed'; + appletName: string; + }; export type ProfilesLocation = { authenticationToken: AppAuthenticationToken; @@ -589,21 +613,21 @@ export type ReadonlyPeerStatusStore = Readable = | { - status: 'pending'; - } + status: 'pending'; + } | { - status: 'complete'; - value: T; - } + status: 'complete'; + value: T; + } | { - status: 'error'; - error: any; - }; + status: 'error'; + error: any; + }; export type AssetStore = Readable>; diff --git a/package.json b/package.json index 531130dc..5de99f18 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "applet-dev-example-1": "yarn typecheck && yarn check:binaries && concurrently \"yarn workspace @theweave/api build:watch\" \"yarn workspace @theweave/elements build:watch\" \"UI_PORT=8888 yarn workspace example-applet start\" \"electron-vite dev -- --dev-config weave.dev.config.example.ts --agent-idx 1 --holochain-wasm-log=debug --print-holochain-logs\"", "applet-dev-example": "yarn typecheck && yarn check:binaries && concurrently \"yarn workspace @theweave/api build:watch\" \"yarn workspace @theweave/elements build:watch\" \"UI_PORT=8888 yarn workspace example-applet start\" \"electron-vite dev -- --dev-config weave.test.config.example.ts --agent-idx 1\" \"sleep 10 && electron-vite dev -- --dev-config weave.test.config.example.ts --agent-idx 2\"", "applet-dev-custom": "yarn typecheck && yarn check:binaries && concurrently \"yarn workspace @theweave/api build:watch\" \"yarn workspace @theweave/elements build:watch\" \"UI_PORT=8888 yarn workspace example-applet start\" \"electron-vite dev -- --dev-config weave.dev.config.ts --agent-idx 1\" \"sleep 10 && electron-vite dev -- --dev-config weave.dev.config.ts --agent-idx 2\"", + "applet-dev-custom2": "yarn typecheck && yarn check:binaries && concurrently \"yarn workspace @theweave/api build:watch\" \"yarn workspace @theweave/elements build:watch\" \"UI_PORT=8888 yarn workspace example-applet start\" \"electron-vite dev -- --dev-config weave.dev2.config.ts --agent-idx 1\" \"sleep 10 && electron-vite dev -- --dev-config weave.dev2.config.ts --agent-idx 2\"", "dev": "yarn typecheck && yarn check:binaries && concurrently \"yarn workspace @theweave/api build:watch\" \"yarn workspace @theweave/elements build:watch\" \"UI_PORT=8888 yarn workspace example-applet start\" \"electron-vite dev -- --dev-config weave.dev.config.ts --agent-idx 1\"", "dev:electron": "yarn typecheck && electron-vite dev", "build": "yarn typecheck && yarn build:applet-iframe && electron-vite build && yarn build:cli", diff --git a/src/renderer/src/elements/_new_design/navigation/applet-sidebar-button.ts b/src/renderer/src/elements/_new_design/navigation/applet-sidebar-button.ts index 9b8a0ea7..6ad00b55 100644 --- a/src/renderer/src/elements/_new_design/navigation/applet-sidebar-button.ts +++ b/src/renderer/src/elements/_new_design/navigation/applet-sidebar-button.ts @@ -64,6 +64,12 @@ export class AppletSidebarButton extends LitElement { () => [this.appletStore], ); + appletLifecycleState = new StoreSubscriber( + this, + () => this.mossStore.appletLifecycleState(this.appletStore.appletHash), + () => [this.mossStore, this.appletStore], + ); + notificationUrgency(): string | undefined { return this.appletNotificationStatus.value[0]; } @@ -72,6 +78,22 @@ export class AppletSidebarButton extends LitElement { return this.appletNotificationStatus.value[1]; } + getLifecycleColor(): string { + const state = this.appletLifecycleState.value; + switch (state) { + case 'active': + return 'black'; + case 'inactive': + return 'green'; + case 'suspended': + return 'yellow'; + case 'discarded': + return 'red'; + default: + return 'black'; + } + } + renderLogo() { switch (this.appletLogo.value.status) { case 'pending': @@ -114,7 +136,7 @@ export class AppletSidebarButton extends LitElement {
${this.renderLogo()}
${this.collapsed ? html`` - : html`
+ : html`
${this.appletStore.applet.custom_name}
`}
diff --git a/src/renderer/src/groups/group-store.ts b/src/renderer/src/groups/group-store.ts index 8d616236..2e45c975 100644 --- a/src/renderer/src/groups/group-store.ts +++ b/src/renderer/src/groups/group-store.ts @@ -83,12 +83,23 @@ import { import isEqual from 'lodash-es/isEqual.js'; import { ToolAndCurationInfo } from '../types.js'; -export const NEW_APPLETS_POLLING_FREQUENCY = 10000; +// Performance markers for tool setup measurement (Method 2) +const PERF_MARKERS = { + GROUP_SETUP_START: 'group-setup-start', + RUNNING_APPLETS_START: 'running-applets-load-start', + RUNNING_APPLETS_END: 'running-applets-load-end', + APPLET_STORES_START: 'applet-stores-init-start', + APPLET_STORES_END: 'applet-stores-init-end', + FIRST_APPLET_READY: 'first-applet-ready', + ALL_APPLETS_READY: 'all-applets-ready', +}; + +export const NEW_APPLETS_POLLING_FREQUENCY = 15000; const AGENTS_REFETCH_FREQUENCY = 10; const PING_AGENTS_FREQUENCY_MS = 8000; export const OFFLINE_THRESHOLD = 26000; // Peer is considered offline if they did not respond to 3 consecutive pings export const IDLE_THRESHOLD = 300000; // Peer is considered inactive after 5 minutes without interaction inside Moss -const ASSET_RELATION_POLLING_PERIOD = 10000; +const ASSET_RELATION_POLLING_PERIOD = 30000; export type MaybeProfile = | { @@ -144,6 +155,9 @@ export class GroupStore { public mossStore: MossStore, public assetsClient: AssetsClient, ) { + // Performance marker: Group setup start + performance.mark(PERF_MARKERS.GROUP_SETUP_START); + this.groupClient = new GroupClient(appWebsocket, authenticationToken, 'group'); this.peerStatusClient = new PeerStatusClient(appWebsocket, 'group'); @@ -1101,6 +1115,9 @@ export class GroupStore { }); allMyRunningApplets = manualReloadStore(async () => { + // Performance marker: Running applets load start + performance.mark(PERF_MARKERS.RUNNING_APPLETS_START); + const allMyApplets = await (async () => { if (!this.constructed) { return retryUntilResolved>( @@ -1126,6 +1143,23 @@ export class GroupStore { const output = allMyApplets.filter((appletHash) => runningAppIds.includes(`applet#${toLowerCaseB64(encodeHashToBase64(appletHash))}`), ); + + // Performance marker: Running applets load end + performance.mark(PERF_MARKERS.RUNNING_APPLETS_END); + performance.measure( + 'running-applets-load', + PERF_MARKERS.RUNNING_APPLETS_START, + PERF_MARKERS.RUNNING_APPLETS_END, + ); + + const measure = performance.getEntriesByName('running-applets-load'); + if (measure.length > 0) { + const lastMeasure = measure[measure.length - 1]; + console.log( + `[PERF] Running applets load: ${lastMeasure.duration.toFixed(2)}ms (${output.length} applets)`, + ); + } + // console.log( // 'Got allMyRunningApplets: ', // output.map((h) => encodeHashToBase64(h)), diff --git a/src/renderer/src/layout/views/view-frame.ts b/src/renderer/src/layout/views/view-frame.ts index ffad86a7..55813884 100644 --- a/src/renderer/src/layout/views/view-frame.ts +++ b/src/renderer/src/layout/views/view-frame.ts @@ -2,7 +2,8 @@ import { css, html, LitElement, PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { encodeHashToBase64 } from '@holochain/client'; import { consume } from '@lit/context'; -import { IframeKind, RenderView } from '@theweave/api'; +import { IframeKind, RenderView, LifecycleState } from '@theweave/api'; +import { StoreSubscriber } from '@holochain-open-dev/stores'; import { mossStyles } from '../../shared-styles.js'; import { iframeOrigin, renderViewToQueryString } from '../../utils.js'; @@ -14,6 +15,16 @@ import '@shoelace-style/shoelace/dist/components/button/button.js'; import { encode } from '@msgpack/msgpack'; import { fromUint8Array } from 'js-base64'; +// Performance markers for tool setup measurement (Method 2) +const PERF_MARKERS = { + GROUP_SETUP_START: 'group-setup-start', + FIRST_APPLET_READY: 'first-applet-ready', + ALL_APPLETS_READY: 'all-applets-ready', +}; + +// Track which applets have been marked as ready +const readyApplets = new Set(); + @localized() @customElement('view-frame') export class ViewFrame extends LitElement { @@ -42,10 +53,47 @@ export class ViewFrame extends LitElement { @state() slowReloadTimeout: number | undefined; + @state() + lifecycleState: LifecycleState = 'active'; + + @state() + inactivityTimer: number | undefined; + + @state() + suspendedTimer: number | undefined; + + // Configuration + private readonly INACTIVITY_TO_SUSPENDED = 10 * 1000 //5 * 60 * 1000; // 5 minutes + private readonly SUSPENDED_TO_DISCARDED = 30 * 1000 //30 * 60 * 1000; // 30 minutes + + private _dashboardState = new StoreSubscriber( + this, + () => this.mossStore.dashboardState(), + () => [this.mossStore], + ); + + private _previousDashboardState: typeof this._dashboardState.value | undefined; + async firstUpdated() { if (this.mossStore.isAppletDev) { this.appletDevPort = await this.mossStore.getAppletDevPort(this.iframeKind); } + + // Track when iframe starts loading (for main applet views) + if ( + this.renderView.type === 'applet-view' && + this.renderView.view.type === 'main' && + this.iframeKind.type === 'applet' + ) { + const appletId = encodeHashToBase64(this.iframeKind.appletHash); + if (!readyApplets.has(appletId)) { + // This is a new applet iframe being created + console.log(`[PERF DEBUG] Starting to load applet: ${appletId}`); + } + // Initialize lifecycle state to 'active' for new iframes + this.mossStore.setAppletLifecycleState(this.iframeKind.appletHash, 'active'); + this.lifecycleState = 'active'; + } } willUpdate(changedProperties: PropertyValues) { @@ -66,6 +114,243 @@ export class ViewFrame extends LitElement { } } + updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + + // Update lifecycle state when dashboard state or renderView changes + const currentDashboardState = this._dashboardState.value; + const dashboardStateChanged = + this._previousDashboardState !== currentDashboardState && + JSON.stringify(this._previousDashboardState) !== JSON.stringify(currentDashboardState); + + if (dashboardStateChanged || changedProperties.has('renderView')) { + this._previousDashboardState = currentDashboardState; + this.updateLifecycleState(); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.clearInactivityTimer(); + this.clearSuspendedTimer(); + } + + private updateLifecycleState() { + if (this.renderView.type !== 'applet-view' || this.iframeKind.type !== 'applet') { + return; // Only manage lifecycle for applet main views + } + + const appletHash = this.iframeKind.appletHash; + const dashboardState = this._dashboardState.value; + + // Applet is active if: + // 1. Dashboard is showing a group view + // 2. This applet is the selected applet in that group + const isActive = + dashboardState.viewType === 'group' && + dashboardState.appletHash && + encodeHashToBase64(dashboardState.appletHash) === encodeHashToBase64(appletHash); + + if (isActive) { + // Applet is now active (user selected it, or switched to its group) + this.setLifecycleState('active'); + this.clearInactivityTimer(); + } else { + // Applet is now inactive (user selected different applet, or switched groups) + // Note: Applet remains in lifecycle states even when its group is not active + // It will become active again when user switches back to it + if (this.lifecycleState === 'active') { + this.setLifecycleState('inactive'); + this.startInactivityTimer(); + } + // If already inactive/suspended/discarded, stay in that state + } + } + + private setLifecycleState(state: LifecycleState) { + const previousState = this.lifecycleState; + this.lifecycleState = state; + + // Update MossStore with lifecycle state for UI display + if (this.renderView.type === 'applet-view' && this.iframeKind.type === 'applet') { + this.mossStore.setAppletLifecycleState(this.iframeKind.appletHash, state); + } + + // Notify iframe of lifecycle change (if iframe still exists) + const iframe = this.shadowRoot?.querySelector('iframe') as HTMLIFrameElement | null; + if (iframe && iframe.contentWindow) { + try { + iframe.contentWindow.postMessage( + { + type: 'lifecycle-state-change', + state, + previousState, + }, + '*', + ); + } catch (e) { + // Iframe may be cross-origin or not accessible + console.warn('Failed to send lifecycle state change to iframe:', e); + } + } + + // Handle state transitions + if (state === 'suspended' && previousState === 'inactive') { + this.suspendIframe(); + this.startSuspendedTimer(); + } else if (state === 'discarded' && previousState === 'suspended') { + this.discardIframe(); + } else if (state === 'active' && previousState === 'suspended') { + this.restoreIframe(); + this.clearSuspendedTimer(); + } else if (state === 'active' && previousState === 'discarded') { + this.recreateIframe(); + } else if (state === 'active') { + this.clearSuspendedTimer(); + } + } + + private startInactivityTimer() { + this.clearInactivityTimer(); + + this.inactivityTimer = window.setTimeout(() => { + if (this.lifecycleState === 'inactive') { + this.setLifecycleState('suspended'); + } + }, this.INACTIVITY_TO_SUSPENDED); + } + + private startSuspendedTimer() { + this.clearSuspendedTimer(); + + this.suspendedTimer = window.setTimeout(() => { + if (this.lifecycleState === 'suspended') { + // Check memory pressure before discarding + if (this.checkMemoryPressure()) { + this.setLifecycleState('discarded'); + } else { + // Still discard after timeout, but less aggressively + this.setLifecycleState('discarded'); + } + } + }, this.SUSPENDED_TO_DISCARDED); + } + + private clearInactivityTimer() { + if (this.inactivityTimer) { + clearTimeout(this.inactivityTimer); + this.inactivityTimer = undefined; + } + } + + private clearSuspendedTimer() { + if (this.suspendedTimer) { + clearTimeout(this.suspendedTimer); + this.suspendedTimer = undefined; + } + } + + private checkMemoryPressure(): boolean { + // Use performance.memory API if available (Chrome) + if ('memory' in performance) { + const memory = (performance as any).memory; + const usedMB = memory.usedJSHeapSize / 1048576; + const totalMB = memory.totalJSHeapSize / 1048576; + return usedMB / totalMB > 0.8; // 80% memory usage + } + return false; + } + + private suspendIframe() { + // Remove iframe from DOM but keep reference for quick restore + const iframe = this.shadowRoot?.querySelector('iframe') as HTMLIFrameElement | null; + if (iframe) { + // Notify the iframe to suspend its internal DOM + if (iframe.contentWindow) { + try { + iframe.contentWindow.postMessage( + { + type: 'suspend-dom', + }, + '*', + ); + } catch (e) { + console.warn('Failed to send suspend-dom message to iframe:', e); + } + } + // Hide iframe (still in DOM, just not visible) + iframe.style.display = 'none'; + } + } + + private discardIframe() { + // Keep iframe in DOM but hidden - JavaScript context must remain for background processing + // The iframe's internal DOM can be cleared, but the iframe itself stays alive + const iframe = this.shadowRoot?.querySelector('iframe') as HTMLIFrameElement | null; + if (iframe) { + // Notify iframe to discard its internal DOM (but keep JavaScript context alive) + if (iframe.contentWindow) { + try { + iframe.contentWindow.postMessage( + { + type: 'discard-dom', + }, + '*', + ); + } catch (e) { + console.warn('Failed to send discard-dom message to iframe:', e); + } + } + // Hide iframe completely but keep it in DOM so JavaScript continues running + iframe.style.display = 'none'; + iframe.style.visibility = 'hidden'; + iframe.style.position = 'absolute'; + iframe.style.left = '-9999px'; + iframe.style.width = '1px'; + iframe.style.height = '1px'; + } + } + + private restoreIframe() { + // Restore iframe to visible (quick restore from suspended) + const iframe = this.shadowRoot?.querySelector('iframe') as HTMLIFrameElement | null; + if (iframe) { + // Restore iframe visibility + iframe.style.display = 'block'; + iframe.style.visibility = 'visible'; + iframe.style.position = ''; + iframe.style.left = ''; + iframe.style.width = ''; + iframe.style.height = ''; + // Notify iframe to restore its internal DOM + if (iframe.contentWindow) { + try { + iframe.contentWindow.postMessage( + { + type: 'restore-dom', + }, + '*', + ); + } catch (e) { + console.warn('Failed to send restore-dom message to iframe:', e); + } + } + } + } + + private recreateIframe() { + // Restore iframe from discarded state + // The iframe should still exist (just hidden), so restore it + const iframe = this.shadowRoot?.querySelector('iframe') as HTMLIFrameElement | null; + if (iframe) { + // Iframe still exists, just restore it + this.restoreIframe(); + } else { + // Iframe was actually removed somehow, recreate it + this.requestUpdate(); + } + } + hardRefresh() { this.slowLoading = false; this.dispatchEvent(new CustomEvent('hard-refresh', { bubbles: true, composed: true })); @@ -82,7 +367,7 @@ export class ViewFrame extends LitElement { ${this.reloading ? msg('reloading...') : msg('loading...')} ${this.slowLoading - ? html` + ? html`
This Tool takes unusually long to reload. Do you want to force reload?
@@ -98,26 +383,87 @@ export class ViewFrame extends LitElement {
` - : html``} + : html``}
`; } + handleIframeLoad() { + this.loading = false; + + // Track when applet iframe is ready (for main applet views) + if ( + this.renderView.type === 'applet-view' && + this.renderView.view.type === 'main' && + this.iframeKind.type === 'applet' + ) { + const appletId = encodeHashToBase64(this.iframeKind.appletHash); + if (!readyApplets.has(appletId)) { + readyApplets.add(appletId); + + // Check if this is the first applet ready + if (readyApplets.size === 1) { + performance.mark(PERF_MARKERS.FIRST_APPLET_READY); + + // Check if GROUP_SETUP_START marker exists + const setupStartMark = performance.getEntriesByName(PERF_MARKERS.GROUP_SETUP_START, 'mark'); + if (setupStartMark.length > 0) { + try { + performance.measure( + 'first-applet-ready', + PERF_MARKERS.GROUP_SETUP_START, + PERF_MARKERS.FIRST_APPLET_READY, + ); + + const measure = performance.getEntriesByName('first-applet-ready'); + if (measure.length > 0) { + const lastMeasure = measure[measure.length - 1]; + console.log(`[PERF] First applet ready: ${lastMeasure.duration.toFixed(2)}ms`); + } + } catch (e) { + console.warn('[PERF] Failed to measure first-applet-ready:', e); + // Fallback: measure from navigation start + const navStart = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; + if (navStart) { + const timeSinceNavStart = performance.now() - navStart.fetchStart; + console.log(`[PERF] First applet ready (from nav start): ${timeSinceNavStart.toFixed(2)}ms`); + } + } + } else { + // GROUP_SETUP_START marker doesn't exist, use navigation timing as fallback + const navStart = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; + if (navStart) { + const timeSinceNavStart = performance.now() - navStart.fetchStart; + console.log(`[PERF] First applet ready (from nav start, no group-setup-start): ${timeSinceNavStart.toFixed(2)}ms`); + } else { + console.log(`[PERF] First applet ready: ${appletId}`); + } + } + } + } + } + } + renderProductionFrame() { + // Always render iframe - even when discarded, it stays in DOM (hidden) for background processing + const isHidden = this.loading || + this.lifecycleState === 'suspended' || + this.lifecycleState === 'discarded'; + return html` ${this.renderLoading()}`; } @@ -133,20 +479,26 @@ export class ViewFrame extends LitElement { const iframeSrc = `http://localhost:${this.appletDevPort}?${renderViewToQueryString( this.renderView, )}#${fromUint8Array(encode(this.iframeKind))}`; + + // Always render iframe - even when discarded, it stays in DOM (hidden) for background processing + const isHidden = this.loading || + this.lifecycleState === 'suspended' || + this.lifecycleState === 'discarded'; + return html` ${this.renderLoading()}`; } diff --git a/src/renderer/src/moss-store.ts b/src/renderer/src/moss-store.ts index 3e9b3471..8e775f69 100644 --- a/src/renderer/src/moss-store.ts +++ b/src/renderer/src/moss-store.ts @@ -35,9 +35,8 @@ import { ProvisionedCell, RoleNameCallZomeRequest, } from '@holochain/client'; -import { encodeHashToBase64 } from '@holochain/client'; import { EntryHashB64 } from '@holochain/client'; -import { ActionHash, AdminWebsocket, DnaHash, EntryHash } from '@holochain/client'; +import { ActionHash, AdminWebsocket, DnaHash, EntryHash, encodeHashToBase64 } from '@holochain/client'; import { CreatableResult, CreatableName, @@ -53,6 +52,7 @@ import { IframeKind, ZomeCallLogInfo, ParentToAppletMessage, + LifecycleState, } from '@theweave/api'; import { GroupStore } from './groups/group-store.js'; import { DnaLocation, HrlLocation, locateHrl } from './processes/hrl/locate-hrl.js'; @@ -307,6 +307,39 @@ export class MossStore { visible: false, }); + /** + * Track lifecycle states of applet iframes for UI display + * Map from appletHash (base64) to lifecycle state + */ + private _appletLifecycleStates: Writable> = writable(new Map()); + + /** + * Get the lifecycle state of an applet (readable store) + */ + appletLifecycleState(appletHash: AppletHash): Readable { + const appletId = encodeHashToBase64(appletHash); + return derived(this._appletLifecycleStates, (states) => states.get(appletId) || 'active'); + } + + /** + * Get the lifecycle state of an applet (synchronous getter) + */ + getAppletLifecycleState(appletHash: AppletHash): LifecycleState { + const appletId = encodeHashToBase64(appletHash); + return get(this._appletLifecycleStates).get(appletId) || 'active'; + } + + /** + * Set the lifecycle state of an applet + */ + setAppletLifecycleState(appletHash: AppletHash, state: LifecycleState): void { + const appletId = encodeHashToBase64(appletHash); + const currentStates = get(this._appletLifecycleStates); + const newStates = new Map(currentStates); + newStates.set(appletId, state); + this._appletLifecycleStates.set(newStates); + } + assetViewerState(): Readable { return derived(this._assetViewerState, (state) => state); } @@ -1296,6 +1329,10 @@ export class MossStore { appletStores = new LazyHoloHashMap((appletHash: EntryHash) => asyncReadable(async (set) => { + // Performance marker: Applet store init start + const appletStoreMarker = `applet-store-init-${encodeHashToBase64(appletHash)}`; + performance.mark(`${appletStoreMarker}-start`); + // console.log("@appletStores: attempting to get AppletStore for applet with hash: ", encodeHashToBase64(appletHash)); const groups = await toPromise(this.groupsForApplet.get(appletHash)); // console.log( @@ -1315,6 +1352,24 @@ export class MossStore { const token = await this.getAuthenticationToken(appIdFromAppletHash(appletHash)); + // Performance marker: Applet store init end + performance.mark(`${appletStoreMarker}-end`); + performance.measure( + appletStoreMarker, + `${appletStoreMarker}-start`, + `${appletStoreMarker}-end`, + ); + + const measure = performance.getEntriesByName(appletStoreMarker); + if (measure.length > 0) { + const lastMeasure = measure[measure.length - 1]; + if (lastMeasure.duration > 100) { + console.log( + `[PERF] Slow applet store init: ${encodeHashToBase64(appletHash)} took ${lastMeasure.duration.toFixed(2)}ms`, + ); + } + } + set(new AppletStore(appletHash, applet, this.conductorInfo, token, this.isAppletDev)); }), ); diff --git a/src/renderer/src/utils/performance-logger.ts b/src/renderer/src/utils/performance-logger.ts new file mode 100644 index 00000000..7ea4f9e6 --- /dev/null +++ b/src/renderer/src/utils/performance-logger.ts @@ -0,0 +1,82 @@ +interface PerformanceMetric { + name: string; + startTime: number; + endTime?: number; + duration?: number; + metadata?: Record; +} + +class PerformanceLogger { + private metrics: PerformanceMetric[] = []; + private enabled: boolean = true; + + start(name: string, metadata?: Record): void { + if (!this.enabled) return; + this.metrics.push({ + name, + startTime: performance.now(), + metadata, + }); + } + + end(name: string): number | undefined { + if (!this.enabled) return; + const metric = this.metrics.find((m) => m.name === name && !m.endTime); + if (metric) { + metric.endTime = performance.now(); + metric.duration = metric.endTime - metric.startTime; + return metric.duration; + } + return undefined; + } + + log(name: string): void { + const duration = this.end(name); + if (duration !== undefined) { + console.log(`[PERF] ${name}: ${duration.toFixed(2)}ms`); + } + } + + getMetrics(): PerformanceMetric[] { + return this.metrics.filter((m) => m.duration !== undefined); + } + + getSummary(): Record { + const summary: Record = {}; + this.getMetrics().forEach((m) => { + if (!summary[m.name]) summary[m.name] = []; + summary[m.name].push(m.duration!); + }); + + const result: Record = {}; + Object.entries(summary).forEach(([name, durations]) => { + result[name] = { + count: durations.length, + avg: durations.reduce((a, b) => a + b, 0) / durations.length, + max: Math.max(...durations), + min: Math.min(...durations), + }; + }); + return result; + } + + printSummary(): void { + const summary = this.getSummary(); + console.table(summary); + } + + clear(): void { + this.metrics = []; + } + + enable(): void { + this.enabled = true; + } + + disable(): void { + this.enabled = false; + } +} + +export const perfLogger = new PerformanceLogger(); +