From 04b46c7ba1097e949aa5ae585f5691018985e6fd Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Wed, 3 Dec 2025 17:54:35 +0800 Subject: [PATCH] feat: add architecture documentation for MPA and Multi MemoryRouter - Introduced new markdown files detailing the architecture for Multi Page Application (MPA) with process isolation and Multi MemoryRouter instances. - The MPA document outlines the concept, configuration, implementation, and performance analysis, emphasizing faster initial load and crash isolation. - The Multi MemoryRouter document presents a recommended architecture for state preservation and tab management, addressing key requirements and providing a migration strategy. - The Single Router + Outlet document describes a hybrid approach for tab management, focusing on state synchronization and webview handling. This addition enhances the project's documentation, providing clear guidelines for future development and architectural decisions. --- .../layout/architecture-mpa-webcontents.md | 575 ++++++++++++++++++ .../architecture-multi-memory-router.md | 474 +++++++++++++++ ...cture.md => architecture-single-outlet.md} | 5 +- 3 files changed, 1053 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/layout/architecture-mpa-webcontents.md create mode 100644 src/renderer/src/components/layout/architecture-multi-memory-router.md rename src/renderer/src/components/layout/{router-architecture.md => architecture-single-outlet.md} (95%) diff --git a/src/renderer/src/components/layout/architecture-mpa-webcontents.md b/src/renderer/src/components/layout/architecture-mpa-webcontents.md new file mode 100644 index 0000000000..9904c961e5 --- /dev/null +++ b/src/renderer/src/components/layout/architecture-mpa-webcontents.md @@ -0,0 +1,575 @@ +# Architecture: MPA + Multi WebContents (Process Isolation) + +> **Version**: v1.0.0 +> **Updated**: 2025-12-03 +> **Status**: Research & Analysis +> **Core Idea**: Each Tab is an independent WebContents/BrowserView, project structured as MPA with multiple Vite entry points + +## 1. Concept Overview + +### Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Main Process │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ Tab Manager (controls WebContents lifecycle) │ │ +│ └────────────────────────────────────────────────────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ BrowserWindow (Main Shell) │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Tab Bar (minimal renderer - shell.html) │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Content Area (WebContentsView container) │ │ +│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ +│ │ │ WebContents│ │ WebContents│ │ WebContents│ │ │ +│ │ │ chat.html │ │settings. │ │ notes.html │ │ │ +│ │ │ (visible) │ │ (hidden) │ │ (hidden) │ │ │ +│ │ └────────────┘ └────────────┘ └────────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Core Difference from Other Solutions + +| Aspect | Multi MemoryRouter | MPA + WebContents | +|--------|-------------------|-------------------| +| **Process Model** | Single renderer process | Multiple renderer processes | +| **Isolation** | Shared memory, React context | Complete process isolation | +| **Crash Impact** | One crash affects all | One crash isolated | +| **Memory** | Shared React runtime | Each has own runtime | +| **Communication** | Direct state/props | IPC required | +| **Bundle Size** | One large bundle | Multiple smaller bundles | +| **First Paint** | Slower (load entire app) | Faster (load only needed page) | + +--- + +## 2. Vite MPA Configuration + +### Current State + +Cherry Studio already uses MPA structure: + +```typescript +// electron.vite.config.ts (existing) +renderer: { + build: { + rollupOptions: { + input: { + index: 'src/renderer/index.html', + miniWindow: 'src/renderer/miniWindow.html', + selectionToolbar: 'src/renderer/selectionToolbar.html', + selectionAction: 'src/renderer/selectionAction.html', + traceWindow: 'src/renderer/traceWindow.html', + migrationV2: 'src/renderer/migrationV2.html' + } + } + } +} +``` + +### Proposed Extension + +```typescript +// electron.vite.config.ts (extended for tabs) +renderer: { + build: { + rollupOptions: { + input: { + // Shell (minimal - just tab bar) + shell: 'src/renderer/shell.html', + + // Tab pages (each is independent) + chat: 'src/renderer/pages/chat.html', + settings: 'src/renderer/pages/settings.html', + notes: 'src/renderer/pages/notes.html', + knowledge: 'src/renderer/pages/knowledge.html', + files: 'src/renderer/pages/files.html', + + // Existing special windows + miniWindow: 'src/renderer/miniWindow.html', + selectionToolbar: 'src/renderer/selectionToolbar.html', + } + } + } +} +``` + +### Directory Structure + +``` +src/renderer/ +├── shell.html # Tab bar shell (minimal) +├── pages/ +│ ├── chat/ +│ │ ├── index.html +│ │ ├── main.tsx # Independent React app +│ │ └── App.tsx +│ ├── settings/ +│ │ ├── index.html +│ │ ├── main.tsx +│ │ └── App.tsx +│ ├── notes/ +│ │ └── ... +│ └── shared/ # Shared components/utils +│ ├── components/ +│ ├── hooks/ +│ └── stores/ +└── index.html # Legacy entry (redirect to shell) +``` + +--- + +## 3. WebContentsView Implementation + +### Main Process Tab Manager + +```typescript +// src/main/services/TabManager.ts +import { BaseWindow, WebContentsView } from 'electron' + +interface Tab { + id: string + type: string + view: WebContentsView + url: string +} + +class TabManager { + private mainWindow: BaseWindow + private tabs: Map = new Map() + private activeTabId: string | null = null + private shellView: WebContentsView + + constructor(mainWindow: BaseWindow) { + this.mainWindow = mainWindow + this.initShell() + } + + private initShell() { + // Shell only contains tab bar UI + this.shellView = new WebContentsView() + this.shellView.webContents.loadFile('out/renderer/shell.html') + this.mainWindow.contentView.addChildView(this.shellView) + this.layoutShell() + } + + createTab(type: string, initialUrl?: string): string { + const id = `tab-${Date.now()}` + const view = new WebContentsView() + + // Load the appropriate page based on type + const pageUrl = this.getPageUrl(type) + view.webContents.loadFile(pageUrl) + + // Pass initial state via postMessage after load + if (initialUrl) { + view.webContents.once('did-finish-load', () => { + view.webContents.send('init-state', { url: initialUrl }) + }) + } + + this.tabs.set(id, { id, type, view, url: initialUrl || '' }) + this.mainWindow.contentView.addChildView(view) + this.setActiveTab(id) + + return id + } + + setActiveTab(id: string) { + // Hide all tabs, show active one + this.tabs.forEach((tab, tabId) => { + if (tabId === id) { + tab.view.setBounds(this.getContentBounds()) + // Bring to front + this.mainWindow.contentView.addChildView(tab.view) + } else { + // Hide by setting zero bounds + tab.view.setBounds({ x: 0, y: 0, width: 0, height: 0 }) + } + }) + this.activeTabId = id + this.notifyShell() + } + + closeTab(id: string) { + const tab = this.tabs.get(id) + if (tab) { + this.mainWindow.contentView.removeChildView(tab.view) + tab.view.webContents.close() + this.tabs.delete(id) + + // Activate another tab + if (this.activeTabId === id) { + const remaining = Array.from(this.tabs.keys()) + if (remaining.length > 0) { + this.setActiveTab(remaining[remaining.length - 1]) + } + } + } + } + + // Native tab detach - state fully preserved! + detachTab(id: string): BaseWindow { + const tab = this.tabs.get(id) + if (!tab) throw new Error('Tab not found') + + // Remove from current window + this.mainWindow.contentView.removeChildView(tab.view) + this.tabs.delete(id) + + // Create new window and add the view + const newWindow = new BaseWindow({ width: 800, height: 600 }) + newWindow.contentView.addChildView(tab.view) + tab.view.setBounds({ x: 0, y: 0, width: 800, height: 600 }) + + // State is completely preserved: + // ✅ Scroll position + // ✅ Form inputs + // ✅ React state + // ✅ WebSocket connections + + return newWindow + } + + private getPageUrl(type: string): string { + const pageMap: Record = { + chat: 'out/renderer/pages/chat.html', + settings: 'out/renderer/pages/settings.html', + notes: 'out/renderer/pages/notes.html', + knowledge: 'out/renderer/pages/knowledge.html', + files: 'out/renderer/pages/files.html', + } + return pageMap[type] || pageMap.chat + } + + private getContentBounds() { + const bounds = this.mainWindow.getBounds() + const TAB_BAR_HEIGHT = 40 + return { + x: 0, + y: TAB_BAR_HEIGHT, + width: bounds.width, + height: bounds.height - TAB_BAR_HEIGHT + } + } + + private notifyShell() { + const tabsData = Array.from(this.tabs.values()).map(t => ({ + id: t.id, + type: t.type, + url: t.url + })) + this.shellView.webContents.send('tabs-updated', { + tabs: tabsData, + activeTabId: this.activeTabId + }) + } +} +``` + +### IPC Communication + +```typescript +// src/main/ipc/tabIpc.ts +ipcMain.handle('tab:create', (_, type: string, url?: string) => { + return tabManager.createTab(type, url) +}) + +ipcMain.handle('tab:close', (_, id: string) => { + tabManager.closeTab(id) +}) + +ipcMain.handle('tab:activate', (_, id: string) => { + tabManager.setActiveTab(id) +}) + +ipcMain.handle('tab:detach', (_, id: string) => { + return tabManager.detachTab(id) +}) + +// Cross-tab communication +ipcMain.handle('tab:broadcast', (_, channel: string, data: any) => { + tabManager.broadcastToAll(channel, data) +}) +``` + +--- + +## 4. Shared State Management + +### Challenge + +Each WebContents is a separate process - they cannot directly share memory/state. + +### Solution: State Synchronization via Main Process + +```typescript +// packages/shared/stores/syncedStore.ts +import { ipcRenderer } from 'electron' + +// Each page creates its own store instance, synced via IPC +export const createSyncedStore = (name: string, initialState: T) => { + let state = initialState + const listeners = new Set<(state: T) => void>() + + // Listen for state updates from main process + ipcRenderer.on(`store:${name}:update`, (_, newState: T) => { + state = newState + listeners.forEach(fn => fn(state)) + }) + + // Request initial state on load + ipcRenderer.invoke('store:get', name).then(s => { + if (s) { + state = s + listeners.forEach(fn => fn(state)) + } + }) + + return { + getState: () => state, + + setState: (partial: Partial) => { + // Send to main process, which broadcasts to all tabs + ipcRenderer.invoke('store:update', name, partial) + }, + + subscribe: (fn: (state: T) => void) => { + listeners.add(fn) + return () => listeners.delete(fn) + } + } +} +``` + +### Main Process State Hub + +```typescript +// src/main/services/StateHub.ts +class StateHub { + private stores: Map = new Map() + + getStore(name: string) { + return this.stores.get(name) + } + + updateStore(name: string, partial: any) { + const current = this.stores.get(name) || {} + const updated = { ...current, ...partial } + this.stores.set(name, updated) + + // Broadcast to all WebContents + tabManager.broadcastToAll(`store:${name}:update`, updated) + } +} +``` + +--- + +## 5. Performance Analysis + +### Bundle Size Comparison + +| Scenario | SPA (Current) | MPA (Proposed) | +|----------|---------------|----------------| +| **Total Bundle** | ~3-5MB (one file) | ~3-5MB (split) | +| **Initial Load** | Load all ~3-5MB | Shell ~100KB + Page ~500KB-1MB | +| **Chat Page Only** | Must load everything | Load only chat code | +| **Settings Page** | Already in memory | Load on demand | + +### First Paint Time + +``` +SPA Approach: +┌─────────────────────────────────────────────────────────┐ +│ Load index.html → Parse 3MB JS → React hydration → Ready│ +│ [=====================================] ~2-3s │ +└─────────────────────────────────────────────────────────┘ + +MPA Approach: +┌─────────────────────────────────────────────────────────┐ +│ Load shell.html → Tab bar ready │ +│ [=====] ~300ms │ +│ │ +│ Load chat.html → Parse 800KB JS → Ready │ +│ [============] ~800ms │ +└─────────────────────────────────────────────────────────┘ +Total perceived: ~1.1s (faster than SPA) +``` + +### Memory Usage Comparison + +| Tabs Open | SPA + MemoryRouter | MPA + WebContents | +|-----------|-------------------|-------------------| +| 1 tab | ~150MB | ~150MB | +| 3 tabs | ~200MB (shared) | ~350MB (3 processes) | +| 5 tabs | ~250MB (shared) | ~550MB (5 processes) | +| 10 tabs | ~350MB (shared) | **~1GB+** (10 processes) | + +**Trade-off**: MPA uses more memory but provides better isolation and faster initial load. + +--- + +## 6. Tab Detach - The Killer Feature + +### Native Support (No Serialization Needed) + +```typescript +// WebContentsView can be moved between windows natively +detachTab(id: string) { + const tab = this.tabs.get(id) + + // Remove from current window + this.mainWindow.contentView.removeChildView(tab.view) + + // Create new window + const newWindow = new BaseWindow() + + // Add existing WebContentsView - NO RELOAD! + newWindow.contentView.addChildView(tab.view) + + // Everything preserved: + // ✅ Scroll position + // ✅ Form inputs + // ✅ React component state + // ✅ WebSocket connections + // ✅ Pending requests + // ✅ Animation state +} +``` + +### Comparison with MemoryRouter Approach + +| Aspect | MemoryRouter Detach | WebContents Detach | +|--------|--------------------|--------------------| +| **Implementation** | Serialize → IPC → Deserialize | Move view reference | +| **State Loss** | Some (non-serializable) | None | +| **Scroll Position** | Manual restore | Preserved | +| **WebSocket** | Reconnect needed | Preserved | +| **Complexity** | High | Low (native) | + +--- + +## 7. Advantages & Disadvantages + +### Advantages + +| Feature | Description | +|---------|-------------| +| **Faster First Paint** | Load only needed page, not entire app | +| **Crash Isolation** | One tab crash doesn't affect others | +| **Native Tab Detach** | Move WebContentsView between windows | +| **Independent Updates** | Can update one page without rebuilding all | +| **Memory Isolation** | Each tab has isolated memory space | +| **Parallel Loading** | Multiple tabs can load simultaneously | + +### Disadvantages + +| Feature | Description | +|---------|-------------| +| **Higher Memory** | Each WebContents ~100-150MB base | +| **IPC Overhead** | Cross-tab communication slower | +| **State Sync Complexity** | Need to implement state hub | +| **Code Duplication** | Shared code loaded in each process | +| **Dev Experience** | HMR per page, not global | +| **No Direct State Sharing** | Cannot share React context | + +--- + +## 8. Comparison Summary + +| Feature | Multi MemoryRouter | MPA + WebContents | +|---------|-------------------|-------------------| +| **First Paint Speed** | Slower (load all) | ✅ Faster (per page) | +| **Tab Switch Speed** | ✅ Instant (CSS) | Instant (view swap) | +| **Memory Efficiency** | ✅ Better (shared) | Worse (per process) | +| **Crash Isolation** | ❌ No | ✅ Yes | +| **Tab Detach** | Needs serialization | ✅ Native support | +| **State Sharing** | ✅ Direct | IPC required | +| **Code Complexity** | Medium | Higher | +| **TSR Features** | ✅ Full | N/A (separate apps) | + +--- + +## 9. When to Choose This Architecture + +### Good Fit + +- Tab detach to window is critical requirement +- Each tab is largely independent (different features) +- Crash isolation is important +- Initial load performance is priority +- Large application with heavy pages + +### Not Ideal + +- Frequent cross-tab state sharing needed +- Memory is constrained +- Simple tab switching without detach need +- Small application +- Need shared React context/providers + +--- + +## 10. Hybrid Approach (Recommended) + +Combine both architectures for best results: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Main Window │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ Primary WebContentsView (main.html) │ │ +│ │ ┌─────────────────────────────────────────────┐ │ │ +│ │ │ React App with Multi MemoryRouter │ │ │ +│ │ │ - Chat tabs (MemoryRouter instances) │ │ │ +│ │ │ - Settings (MemoryRouter instance) │ │ │ +│ │ │ - Notes (MemoryRouter instance) │ │ │ +│ │ └─────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ Secondary WebContentsViews (isolated content) │ │ +│ │ - MinApp webviews │ │ +│ │ - External web pages │ │ +│ │ - Heavy isolated features │ │ +│ └───────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### Hybrid Benefits + +- Regular tabs use MemoryRouter (efficient, shared state) +- WebContents only for webviews/external content +- Tab detach: serialize MemoryRouter state → create new WebContents +- Best of both worlds + +--- + +## 11. Open Questions + +- [ ] What is the acceptable memory overhead per WebContents? +- [ ] How many concurrent tabs are expected in typical usage? +- [ ] Is crash isolation a real requirement for this app? +- [ ] Should detached windows be full-featured or minimal? +- [ ] How to handle shared authentication state across WebContents? + +--- + +## 12. References + +- [Electron WebContentsView API](https://www.electronjs.org/docs/latest/api/web-contents-view) +- [Figma BrowserView Architecture](https://www.figma.com/blog/introducing-browserview-for-electron/) +- [electron-vite Multi-Page Setup](https://electron-vite.org/guide/dev) +- [Vite MPA Configuration](https://vite-workshop.netlify.app/mpa) +- [Electron Multi-Tab Performance](https://dev.to/thanhlm/electron-multiple-tabs-without-dealing-with-performance-2cma) +- [Electron Process Model](https://www.electronjs.org/docs/latest/tutorial/process-model) + +--- + +## 13. Changelog + +| Version | Date | Changes | +|---------|------|---------| +| v1.0.0 | 2025-12-03 | Initial analysis | diff --git a/src/renderer/src/components/layout/architecture-multi-memory-router.md b/src/renderer/src/components/layout/architecture-multi-memory-router.md new file mode 100644 index 0000000000..d9af072262 --- /dev/null +++ b/src/renderer/src/components/layout/architecture-multi-memory-router.md @@ -0,0 +1,474 @@ +# Architecture: Multi MemoryRouter Instances (Recommended) + +> **Version**: v1.0.0 +> **Updated**: 2025-12-03 +> **Status**: Research Complete & Recommended +> **Core Idea**: Each Tab has an independent MemoryRouter instance, CSS controls visibility, native KeepAlive + +## 1. Problem Statement + +### Core Contradiction + +``` +URL Router Design Philosophy: URL change → Component switch (single active view) +Tab System Requirement: Multiple views coexist, only switch visibility (preserve state) +``` + +These two are fundamentally conflicting. The current architecture uses TanStack Router's ``, which causes the following issues on each Tab switch: + +- Component unmount/remount +- State loss (scroll position, input content, expand/collapse state) +- White screen flicker + +### Key Requirements + +| Requirement | Priority | Description | +|-------------|----------|-------------| +| No flicker on switch | P0 | UI responds instantly on Tab switch | +| State preservation | P0 | Scroll position, input content, etc. | +| Tab detach to window | P1 | Similar to Chrome/VS Code | +| Memory control | P1 | Support LRU eviction for inactive Tabs | +| URL deep linking | P2 | Support sharing/bookmarks (optional) | + +--- + +## 2. Industry Research + +### 2.1 Electron Application Comparison + +| Project | Tab/Sidebar | Router Solution | KeepAlive | Detach to Window | Tech Stack | +|---------|-------------|-----------------|-----------|------------------|------------| +| **VS Code** | Tabs | No Router | ✅ Self-impl | ✅ Auxiliary Window | Native TS | +| **Figma** | Tabs | None | ✅ BrowserView | ✅ | Electron | +| **Hyper** | Tabs | No Router | ✅ Redux | ❌ | React + Redux | +| **LobeChat** | Sidebar | MemoryRouter (migrating) | ❌ | ❌ | Next.js + Zustand | +| **Jan AI** | Sidebar | None | ❌ | ❌ | Tauri + React | + +### 2.2 VS Code Implementation Analysis + +VS Code 1.85 implemented "Auxiliary Window" feature: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Main Window │ +│ ┌─────────────────────────────────────────────────────┐│ +│ │ Editor Model (document state in memory) ││ +│ │ - file content ││ +│ │ - cursor position ││ +│ │ - undo/redo stack ││ +│ └─────────────────────────────────────────────────────┘│ +│ ↑ shared │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────┐│ +│ │ Auxiliary Window (new BrowserWindow) ││ +│ │ - renders the same Editor Model ││ +│ │ - changes sync in real-time ││ +│ └─────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────┘ +``` + +**Key Features**: + +- All windows operate on the same in-memory Model +- Changes in one window update all others in real-time +- Does not rely on URL Router, pure state-driven + +### 2.3 LobeChat Migration (RFC #9848) + +LobeChat is migrating from RSC to SPA: + +**Migration Reasons**: + +- RSC requires server-side data fetching, blocking page load +- Each navigation requires a server round-trip +- Windows users reported noticeable lag + +**New Solution**: + +- `react-router-dom` + `MemoryRouter` +- Zustand for centralized state management +- SWR/React Query for data fetching + +--- + +## 3. KeepAlive Solutions + +### 3.1 Solution Comparison + +| Solution | Mechanism | Advantages | Disadvantages | +|----------|-----------|------------|---------------| +| **React 19.2 Activity** | Official component, unmounts effects when hidden | Official support, long-term reliable | Requires React 19.2 upgrade | +| **keepalive-for-react** | Portal + cache management | Supports LRU, feature-rich | Incompatible with StrictMode | +| **react-activation** | Portal relocation | More mature | React 18 requires disabling autoFreeze | +| **CSS display:none** | Render all components, CSS controls visibility | Simple and direct | High memory usage | + +### 3.2 TanStack Router + KeepAlive Status + +**Official Stance**: TSR has no built-in KeepAlive + +**Community Solution Issues**: + +- `tanstack-router-keepalive`: `useSearch()` doesn't update, useQuery fails +- Manual implementation: Requires handling RouterContext synchronization + +**Core Problem**: `` only renders the current route, cannot keep multiple route components alive simultaneously. + +### 3.3 Cost of Abandoning Outlet + +If not using ``, TSR feature availability: + +| Feature | Availability | Notes | +|---------|--------------|-------| +| Type-safe route definitions | ✅ Fully available | Route table definition unchanged | +| URL building `Link` | ✅ Fully available | Type-safe URL generation | +| Parameter parsing `useParams` | ⚠️ Needs adaptation | Depends on RouterContext | +| Loader data loading | ❌ Manual call required | Auto-trigger mechanism disabled | +| Nested route rendering | ❌ Self-implementation required | Core Outlet functionality | +| beforeLoad guards | ❌ Manual call required | Route lifecycle | + +--- + +## 4. Recommended Solution + +### 4.1 Solution Choice: TSR MemoryHistory Multi-Instance + +Each Tab has an independent MemoryRouter instance, achieving state isolation and KeepAlive. + +``` +┌─────────────────────────────────────────────────────────┐ +│ AppShell │ +│ ┌─────────────────────────────────────────────────────┐│ +│ │ Tab Bar ││ +│ │ [Chat 1] [Chat 2] [Settings] ││ +│ └─────────────────────────────────────────────────────┘│ +│ ┌─────────────────────────────────────────────────────┐│ +│ │ Tab Contents (coexist, CSS controls visibility) ││ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││ +│ │ │MemoryRouter │ │MemoryRouter │ │MemoryRouter │ ││ +│ │ │ Tab 1 │ │ Tab 2 │ │ Tab 3 │ ││ +│ │ │ visible │ │ hidden │ │ hidden │ ││ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ ││ +│ └─────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────┘ +``` + +### 4.2 Core Advantages + +| Feature | Description | +|---------|-------------| +| **No flicker** | Components not unmounted, CSS hidden | +| **State preservation** | Scroll position, input content fully preserved | +| **TSR capabilities preserved** | useParams, useSearch, nested routes work normally | +| **State isolation** | Each Tab has independent RouterContext | +| **Independent history stack** | Each Tab has its own forward/back | +| **Supports detach to window** | State is serializable | + +### 4.3 Architecture Design + +#### 4.3.1 Tab State Definition + +```typescript +// packages/shared/data/cache/cacheSchemas.ts +export type TabType = 'route' | 'webview' + +export interface Tab { + id: string + type: TabType + url: string // Current URL of MemoryRouter + title: string + icon?: string + // Serializable state (for detaching to window) + scrollPosition?: number + inputDraft?: string + metadata?: Record +} + +export interface TabsState { + tabs: Tab[] + activeTabId: string +} +``` + +#### 4.3.2 Tab Router Component + +```typescript +// src/renderer/src/components/layout/TabRouter.tsx +import { createRouter, RouterProvider } from '@tanstack/react-router' +import { createMemoryHistory } from '@tanstack/react-router' +import { useMemo, useEffect, useRef } from 'react' +import { routeTree } from '../../routeTree.gen' + +interface TabRouterProps { + tab: Tab + isActive: boolean + onUrlChange: (url: string) => void +} + +export const TabRouter = ({ tab, isActive, onUrlChange }: TabRouterProps) => { + // Create independent MemoryRouter for each Tab + const router = useMemo(() => { + const history = createMemoryHistory({ + initialEntries: [tab.url] + }) + + return createRouter({ + routeTree, + history, + }) + }, [tab.id]) // Only initialize when Tab is created + + // Listen to Tab internal navigation, sync URL to Tab state + useEffect(() => { + const unsubscribe = router.subscribe('onResolved', () => { + const currentUrl = router.state.location.pathname + if (currentUrl !== tab.url) { + onUrlChange(currentUrl) + } + }) + return unsubscribe + }, [router, tab.url, onUrlChange]) + + return ( +
+ +
+ ) +} +``` + +#### 4.3.3 AppShell Component + +```typescript +// src/renderer/src/components/layout/AppShell.tsx +import { useTabs } from '../../hooks/useTabs' +import { TabRouter } from './TabRouter' +import { WebviewContainer } from './WebviewContainer' + +export const AppShell = () => { + const { tabs, activeTabId, updateTab, setActiveTab, closeTab, addTab } = useTabs() + + const handleUrlChange = (tabId: string, url: string) => { + updateTab(tabId, { url }) + } + + const handleDetachTab = (tab: Tab) => { + // Serialize state, create new window + window.api.createWindow({ + initialTab: JSON.stringify({ + ...tab, + // Capture current scroll position, etc. + scrollPosition: getScrollPosition(tab.id), + inputDraft: getInputDraft(tab.id), + }) + }) + closeTab(tab.id) + } + + return ( +
+ {/* Sidebar */} + + +
+ {/* Tab Bar */} + + + {/* Tab Contents */} +
+ {tabs.map(tab => { + if (tab.type === 'route') { + return ( + handleUrlChange(tab.id, url)} + /> + ) + } + + if (tab.type === 'webview') { + return ( + + ) + } + + return null + })} +
+
+
+ ) +} +``` + +### 4.4 Tab Detach to Window + +#### 4.4.1 State Serialization + +```typescript +// On detach: serialize current state +const serializeTabState = (tab: Tab): SerializedTabState => { + return { + ...tab, + scrollPosition: captureScrollPosition(tab.id), + inputDraft: captureInputDraft(tab.id), + } +} + +// On new window startup: deserialize to restore state +const deserializeTabState = (state: SerializedTabState): Tab => { + // MemoryRouter will initialize from state.url + // Scroll position, etc. restored after component mounts + return state +} +``` + +#### 4.4.2 IPC Communication + +```typescript +// Main Process +ipcMain.handle('create-window', async (_, options) => { + const newWindow = new BrowserWindow({ + // ... + }) + + // Pass initial state to new window + newWindow.webContents.once('did-finish-load', () => { + newWindow.webContents.send('init-tab', options.initialTab) + }) +}) + +// Renderer Process (new window) +window.api.onInitTab((serializedTab) => { + const tab = deserializeTabState(JSON.parse(serializedTab)) + tabStore.addTab(tab) +}) +``` + +### 4.5 Memory Management + +#### 4.5.1 LRU Eviction Strategy + +```typescript +const MAX_CACHED_TABS = 10 + +const useTabs = () => { + // When Tab count exceeds limit, unload least recently used + useEffect(() => { + if (tabs.length > MAX_CACHED_TABS) { + const sortedByLastAccess = [...tabs].sort( + (a, b) => a.lastAccessTime - b.lastAccessTime + ) + const toRemove = sortedByLastAccess.slice(0, tabs.length - MAX_CACHED_TABS) + + toRemove.forEach(tab => { + // Save state to persistent storage + persistTabState(tab) + // Remove Router instance from memory + unloadTab(tab.id) + }) + } + }, [tabs.length]) +} +``` + +#### 4.5.2 Lazy Load Recovery + +```typescript +// When unloaded Tab is reactivated +const rehydrateTab = async (tabId: string) => { + const persistedState = await loadTabState(tabId) + // Recreate Router instance + // Restore scroll position, etc. +} +``` + +--- + +## 5. Migration Strategy + +### 5.1 Phase 1: Basic Infrastructure + +1. Create `TabRouter` component +2. Modify `AppShell` to support multiple Router instances +3. Update `useTabs` hook + +### 5.2 Phase 2: Feature Completion + +1. Implement Tab detach to window +2. Add LRU memory management +3. State persistence and recovery + +### 5.3 Phase 3: Optimization + +1. Performance optimization (lazy loading, virtualization) +2. Animation transition effects +3. Error boundary handling + +--- + +## 6. Comparison with Alternatives + +### 6.1 Solution Comparison + +| Solution | Flicker | State Preservation | TSR Capabilities | Detach to Window | Complexity | +|----------|---------|-------------------|------------------|------------------|------------| +| **MemoryHistory Multi-Instance** | ✅ None | ✅ Complete | ✅ Complete | ✅ | Medium | +| Pure State Management | ✅ None | ✅ Complete | ❌ Lost | ⚠️ Needs adaptation | Low | +| Outlet + KeepAlive | ✅ None | ✅ Complete | ⚠️ Hook issues | ✅ | Medium | +| BrowserView | ✅ None | ✅ Complete | ❌ Not applicable | ✅ Best | High | +| Original (no KeepAlive) | ❌ Yes | ❌ Lost | ✅ Complete | ✅ | Low | + +### 6.2 Recommendation Rationale + +Reasons for choosing **MemoryHistory Multi-Instance**: + +1. **Preserves TSR capabilities**: useParams, useSearch, nested routes work normally +2. **State isolation**: Each Tab has independent Context, no pollution +3. **Supports detach**: State is serializable +4. **Moderate complexity**: No additional dependencies required + +--- + +## 7. Open Questions + +- [ ] React 19.2 upgrade plan? Is Activity component better? +- [ ] What should be the Tab count limit? +- [ ] Do we need to support Tab grouping? +- [ ] Should Webview Tabs and Route Tabs have different memory strategies? + +--- + +## 8. References + +- [TanStack Router Discussion #1447](https://github.com/TanStack/router/discussions/1447) +- [LobeChat RFC #9848](https://github.com/lobehub/lobe-chat/discussions/9848) +- [VS Code Auxiliary Window](https://github.com/Microsoft/vscode/issues/8171) +- [React 19.2 Activity Component](https://react.dev/blog/2025/10/01/react-19-2) +- [Figma BrowserView](https://www.figma.com/blog/introducing-browserview-for-electron/) +- [keepalive-for-react](https://github.com/irychen/keepalive-for-react) + +--- + +## 9. Changelog + +| Version | Date | Changes | +|---------|------|---------| +| v1.0.0 | 2025-12-03 | Initial research report | diff --git a/src/renderer/src/components/layout/router-architecture.md b/src/renderer/src/components/layout/architecture-single-outlet.md similarity index 95% rename from src/renderer/src/components/layout/router-architecture.md rename to src/renderer/src/components/layout/architecture-single-outlet.md index dc4bbe0b9f..1e134f76f2 100644 --- a/src/renderer/src/components/layout/router-architecture.md +++ b/src/renderer/src/components/layout/architecture-single-outlet.md @@ -1,4 +1,7 @@ -# Router Architecture & Tab System +# Architecture: Single Router + Outlet (KeepAlive Required) + +> **Status**: Initial Design (需要 KeepAlive 库支持) +> **Core Idea**: 单个 HashRouter,依赖 `` 渲染,需要 KeepAlive 包装防止组件卸载 ## Overview