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:
MyPrototypeWhat 2025-12-03 17:54:35 +08:00
parent cf7801f8ec
commit 04b46c7ba1
3 changed files with 1053 additions and 1 deletions

View File

@ -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 |

View File

@ -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 |

View File

@ -1,4 +1,7 @@
# Router Architecture & Tab System
# Architecture: Single Router + Outlet (KeepAlive Required)
> **Status**: Initial Design (需要 KeepAlive 库支持)
> **Core Idea**: 单个 HashRouter依赖 `<Outlet />` 渲染,需要 KeepAlive 包装防止组件卸载
## Overview