mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 11:44:28 +08:00
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.
This commit is contained in:
parent
cf7801f8ec
commit
04b46c7ba1
@ -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<string, Tab> = 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<string, string> = {
|
||||
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 = <T>(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<T>) => {
|
||||
// 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<string, any> = 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 |
|
||||
@ -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 `<Outlet />`, 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**: `<Outlet />` only renders the current route, cannot keep multiple route components alive simultaneously.
|
||||
|
||||
### 3.3 Cost of Abandoning Outlet
|
||||
|
||||
If not using `<Outlet />`, 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<string, unknown>
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
display: isActive ? 'block' : 'none',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### 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 (
|
||||
<div className="flex h-screen w-screen">
|
||||
{/* Sidebar */}
|
||||
<Sidebar onNavigate={handleSidebarClick} />
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
{/* Tab Bar */}
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
onTabClick={setActiveTab}
|
||||
onTabClose={closeTab}
|
||||
onTabDetach={handleDetachTab}
|
||||
/>
|
||||
|
||||
{/* Tab Contents */}
|
||||
<main className="relative flex-1 overflow-hidden">
|
||||
{tabs.map(tab => {
|
||||
if (tab.type === 'route') {
|
||||
return (
|
||||
<TabRouter
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
onUrlChange={(url) => handleUrlChange(tab.id, url)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (tab.type === 'webview') {
|
||||
return (
|
||||
<WebviewContainer
|
||||
key={tab.id}
|
||||
url={tab.url}
|
||||
isActive={tab.id === activeTabId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 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 |
|
||||
@ -1,4 +1,7 @@
|
||||
# Router Architecture & Tab System
|
||||
# Architecture: Single Router + Outlet (KeepAlive Required)
|
||||
|
||||
> **Status**: Initial Design (需要 KeepAlive 库支持)
|
||||
> **Core Idea**: 单个 HashRouter,依赖 `<Outlet />` 渲染,需要 KeepAlive 包装防止组件卸载
|
||||
|
||||
## Overview
|
||||
|
||||
Loading…
Reference in New Issue
Block a user