mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-01 17:59:09 +08:00
fix/miniapp-tab-cache (#10024)
* feat(minapps): add Tabs-mode webview pool and integrate page shell * fix(minapp): position tabs pool below toolbar and preserve layout * style(minapp): fix format issues * style(minapps): optimize var name Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat(minapps): stabilize tab webview lifecycle and mount logic * refactor(minapps): improve webview detection and state handling --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> # Conflicts: # src/renderer/src/components/Tab/TabContainer.tsx
This commit is contained in:
parent
d35998bd74
commit
7217a7216e
143
src/renderer/src/components/MinApp/MinAppTabsPool.tsx
Normal file
143
src/renderer/src/components/MinApp/MinAppTabsPool.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { loggerService } from '@logger'
|
||||
import WebviewContainer from '@renderer/components/MinApp/WebviewContainer'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useNavbarPosition } from '@renderer/hooks/useSettings'
|
||||
import { getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
|
||||
import { WebviewTag } from 'electron'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
/**
|
||||
* Mini-app WebView pool for Tab 模式 (顶部导航).
|
||||
*
|
||||
* 与 Popup 模式相似,但独立存在:
|
||||
* - 仅在 isTopNavbar=true 且访问 /apps 路由时显示
|
||||
* - 保证已打开的 keep-alive 小程序对应的 <webview> 不被卸载,只通过 display 切换
|
||||
* - LRU 淘汰通过 openedKeepAliveMinapps 变化自动移除 DOM
|
||||
*
|
||||
* 后续可演进:与 Popup 共享同一实例(方案 B)。
|
||||
*/
|
||||
const logger = loggerService.withContext('MinAppTabsPool')
|
||||
|
||||
const MinAppTabsPool: React.FC = () => {
|
||||
const { openedKeepAliveMinapps, currentMinappId } = useRuntime()
|
||||
const { isTopNavbar } = useNavbarPosition()
|
||||
const location = useLocation()
|
||||
|
||||
// webview refs(池内部自用,用于控制显示/隐藏)
|
||||
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
|
||||
|
||||
// 使用集中工具进行更稳健的路由判断
|
||||
const isAppDetail = (() => {
|
||||
const pathname = location.pathname
|
||||
if (pathname === '/apps') return false
|
||||
if (!pathname.startsWith('/apps/')) return false
|
||||
const parts = pathname.split('/').filter(Boolean) // ['apps', '<id>', ...]
|
||||
return parts.length >= 2
|
||||
})()
|
||||
const shouldShow = isTopNavbar && isAppDetail
|
||||
|
||||
// 组合当前需要渲染的列表(保持顺序即可)
|
||||
const apps = openedKeepAliveMinapps
|
||||
|
||||
/** 设置 ref 回调 */
|
||||
const handleSetRef = (appid: string, el: WebviewTag | null) => {
|
||||
if (el) {
|
||||
webviewRefs.current.set(appid, el)
|
||||
} else {
|
||||
webviewRefs.current.delete(appid)
|
||||
}
|
||||
}
|
||||
|
||||
/** WebView 加载完成回调 */
|
||||
const handleLoaded = (appid: string) => {
|
||||
setWebviewLoaded(appid, true)
|
||||
logger.debug(`TabPool webview loaded: ${appid}`)
|
||||
}
|
||||
|
||||
/** 记录导航(暂未外曝 URL 状态,后续可接入全局 URL Map) */
|
||||
const handleNavigate = (appid: string, url: string) => {
|
||||
logger.debug(`TabPool webview navigate: ${appid} -> ${url}`)
|
||||
}
|
||||
|
||||
/** 切换显示状态:仅当前 active 的显示,其余隐藏 */
|
||||
useEffect(() => {
|
||||
webviewRefs.current.forEach((ref, id) => {
|
||||
if (!ref) return
|
||||
const active = id === currentMinappId && shouldShow
|
||||
ref.style.display = active ? 'inline-flex' : 'none'
|
||||
})
|
||||
}, [currentMinappId, shouldShow, apps.length])
|
||||
|
||||
/** 当某个已在 Map 里但不再属于 openedKeepAlive 时,移除引用(React 自身会卸载元素) */
|
||||
useEffect(() => {
|
||||
const existing = Array.from(webviewRefs.current.keys())
|
||||
existing.forEach((id) => {
|
||||
if (!apps.find((a) => a.id === id)) {
|
||||
webviewRefs.current.delete(id)
|
||||
// loaded 状态也清理(LRU 已在其它地方清除,双保险)
|
||||
if (getWebviewLoaded(id)) {
|
||||
setWebviewLoaded(id, false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [apps])
|
||||
|
||||
// 不显示时直接 hidden,避免闪烁;仍然保留 DOM 做保活
|
||||
const toolbarHeight = 35 // 与 MinimalToolbar 高度保持一致
|
||||
|
||||
return (
|
||||
<PoolContainer
|
||||
style={
|
||||
shouldShow
|
||||
? {
|
||||
visibility: 'visible',
|
||||
top: toolbarHeight,
|
||||
height: `calc(100% - ${toolbarHeight}px)`
|
||||
}
|
||||
: { visibility: 'hidden' }
|
||||
}
|
||||
data-minapp-tabs-pool
|
||||
aria-hidden={!shouldShow}>
|
||||
{apps.map((app) => (
|
||||
<WebviewWrapper key={app.id} $active={app.id === currentMinappId}>
|
||||
<WebviewContainer
|
||||
appid={app.id}
|
||||
url={app.url}
|
||||
onSetRefCallback={handleSetRef}
|
||||
onLoadedCallback={handleLoaded}
|
||||
onNavigateCallback={handleNavigate}
|
||||
/>
|
||||
</WebviewWrapper>
|
||||
))}
|
||||
</PoolContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const PoolContainer = styled.div`
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
/* top 在运行时通过 style 注入 (toolbarHeight) */
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 8px 8px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
& webview {
|
||||
pointer-events: auto;
|
||||
}
|
||||
`
|
||||
|
||||
const WebviewWrapper = styled.div<{ $active: boolean }>`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* display 控制在内部 webview 元素上做,这里保持结构稳定 */
|
||||
pointer-events: ${(props) => (props.$active ? 'auto' : 'none')};
|
||||
`
|
||||
|
||||
export default MinAppTabsPool
|
||||
@ -115,6 +115,7 @@ const WebviewContainer = memo(
|
||||
<webview
|
||||
key={appid}
|
||||
ref={setRef(appid)}
|
||||
data-minapp-id={appid}
|
||||
style={WebviewStyle}
|
||||
allowpopups={'true' as any}
|
||||
partition="persist:webview"
|
||||
|
||||
@ -39,6 +39,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import MinAppIcon from '../Icons/MinAppIcon'
|
||||
import MinAppTabsPool from '../MinApp/MinAppTabsPool'
|
||||
|
||||
interface TabsContainerProps {
|
||||
children: React.ReactNode
|
||||
@ -270,7 +271,11 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
</SettingsButton>
|
||||
</RightButtonsContainer>
|
||||
</TabsBar>
|
||||
<TabContent>{children}</TabContent>
|
||||
<TabContent>
|
||||
{/* MiniApp WebView 池(Tab 模式保活) */}
|
||||
<MinAppTabsPool />
|
||||
{children}
|
||||
</TabContent>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@ -469,6 +474,7 @@ const TabContent = styled.div`
|
||||
margin-top: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative; /* 约束 MinAppTabsPool 绝对定位范围 */
|
||||
`
|
||||
|
||||
export default TabsContainer
|
||||
|
||||
@ -2,14 +2,18 @@ import { loggerService } from '@logger'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useNavbarPosition } from '@renderer/hooks/useSettings'
|
||||
import TabsService from '@renderer/services/TabsService'
|
||||
import { FC, useEffect, useMemo, useRef } from 'react'
|
||||
import { getWebviewLoaded, onWebviewStateChange, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
|
||||
import { Avatar } from 'antd'
|
||||
import { WebviewTag } from 'electron'
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import BeatLoader from 'react-spinners/BeatLoader'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import MinAppFullPageView from './components/MinAppFullPageView'
|
||||
// Tab 模式下新的页面壳,不再直接创建 WebView,而是依赖全局 MinAppTabsPool
|
||||
import MinimalToolbar from './components/MinimalToolbar'
|
||||
|
||||
const logger = loggerService.withContext('MinAppPage')
|
||||
|
||||
@ -18,7 +22,8 @@ const MinAppPage: FC = () => {
|
||||
const { isTopNavbar } = useNavbarPosition()
|
||||
const { openMinappKeepAlive, minAppsCache } = useMinappPopup()
|
||||
const { minapps } = useMinapps()
|
||||
const { openedKeepAliveMinapps } = useRuntime()
|
||||
// openedKeepAliveMinapps 不再需要作为依赖参与 webview 选择,已通过 MutationObserver 动态发现
|
||||
// const { openedKeepAliveMinapps } = useRuntime()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Remember the initial navbar position when component mounts
|
||||
@ -66,37 +71,146 @@ const MinAppPage: FC = () => {
|
||||
|
||||
// For top navbar mode, integrate with cache system
|
||||
if (initialIsTopNavbar.current) {
|
||||
// Check if app is already in the keep-alive cache
|
||||
const isAlreadyInCache = openedKeepAliveMinapps.some((cachedApp) => cachedApp.id === app.id)
|
||||
|
||||
if (!isAlreadyInCache) {
|
||||
logger.debug(`Adding app ${app.id} to keep-alive cache via openMinappKeepAlive`)
|
||||
// Add to cache without showing popup (for tab mode)
|
||||
openMinappKeepAlive(app)
|
||||
} else {
|
||||
logger.debug(`App ${app.id} already in keep-alive cache`)
|
||||
}
|
||||
// 无论是否已在缓存,都调用以确保 currentMinappId 同步到路由切换的新 appId
|
||||
openMinappKeepAlive(app)
|
||||
}
|
||||
}, [app, navigate, openMinappKeepAlive, openedKeepAliveMinapps, initialIsTopNavbar])
|
||||
}, [app, navigate, openMinappKeepAlive, initialIsTopNavbar])
|
||||
|
||||
// Don't render anything if app not found or not in top navbar mode initially
|
||||
// -------------- 新的 Tab Shell 逻辑 --------------
|
||||
// 注意:Hooks 必须在任何 return 之前调用,因此提前定义,并在内部判空
|
||||
const webviewRef = useRef<WebviewTag | null>(null)
|
||||
const [isReady, setIsReady] = useState<boolean>(() => (app ? getWebviewLoaded(app.id) : false))
|
||||
const [currentUrl, setCurrentUrl] = useState<string | null>(app?.url ?? null)
|
||||
|
||||
// 获取池中的 webview 元素(避免因为 openedKeepAliveMinapps.length 变化而频繁重跑)
|
||||
const webviewCleanupRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const attachWebview = useCallback(() => {
|
||||
if (!app) return true // 没有 app 不再继续监控
|
||||
const selector = `webview[data-minapp-id="${app.id}"]`
|
||||
const el = document.querySelector(selector) as WebviewTag | null
|
||||
if (!el) return false
|
||||
|
||||
if (webviewRef.current === el) return true // 已附着
|
||||
|
||||
webviewRef.current = el
|
||||
const handleInPageNav = (e: any) => setCurrentUrl(e.url)
|
||||
el.addEventListener('did-navigate-in-page', handleInPageNav)
|
||||
webviewCleanupRef.current = () => {
|
||||
el.removeEventListener('did-navigate-in-page', handleInPageNav)
|
||||
}
|
||||
return true
|
||||
}, [app])
|
||||
|
||||
useEffect(() => {
|
||||
if (!app) return
|
||||
|
||||
// 先尝试立即附着
|
||||
if (attachWebview()) return () => webviewCleanupRef.current?.()
|
||||
|
||||
// 若尚未创建,对 DOM 变更做一次监听(轻量 + 自动断开)
|
||||
const observer = new MutationObserver(() => {
|
||||
if (attachWebview()) {
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
webviewCleanupRef.current?.()
|
||||
}
|
||||
}, [app, attachWebview])
|
||||
|
||||
// 事件驱动等待加载完成(移除固定 150ms 轮询)
|
||||
useEffect(() => {
|
||||
if (!app) return
|
||||
if (getWebviewLoaded(app.id)) {
|
||||
// 已经加载
|
||||
if (!isReady) setIsReady(true)
|
||||
return
|
||||
}
|
||||
let mounted = true
|
||||
const unsubscribe = onWebviewStateChange(app.id, (loaded) => {
|
||||
if (!mounted) return
|
||||
if (loaded) {
|
||||
setIsReady(true)
|
||||
unsubscribe()
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
mounted = false
|
||||
unsubscribe()
|
||||
}
|
||||
}, [app, isReady])
|
||||
|
||||
// 如果条件不满足,提前返回(所有 hooks 已调用)
|
||||
if (!app || !initialIsTopNavbar.current) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleReload = () => {
|
||||
if (!app) return
|
||||
if (webviewRef.current) {
|
||||
setWebviewLoaded(app.id, false)
|
||||
setIsReady(false)
|
||||
webviewRef.current.src = app.url
|
||||
setCurrentUrl(app.url)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenDevTools = () => {
|
||||
webviewRef.current?.openDevTools()
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<MinAppFullPageView app={app} />
|
||||
</Container>
|
||||
<ShellContainer>
|
||||
<ToolbarWrapper>
|
||||
<MinimalToolbar
|
||||
app={app}
|
||||
webviewRef={webviewRef}
|
||||
// currentUrl 可能为 null(尚未捕获导航),外部打开时会 fallback 到 app.url
|
||||
currentUrl={currentUrl}
|
||||
onReload={handleReload}
|
||||
onOpenDevTools={handleOpenDevTools}
|
||||
/>
|
||||
</ToolbarWrapper>
|
||||
{!isReady && (
|
||||
<LoadingMask>
|
||||
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />
|
||||
<BeatLoader color="var(--color-text-2)" size={8} style={{ marginTop: 12 }} />
|
||||
</LoadingMask>
|
||||
)}
|
||||
</ShellContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
const ShellContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
z-index: 3; /* 高于池中的 webview */
|
||||
pointer-events: none; /* 让下层 webview 默认可交互 */
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
`
|
||||
|
||||
const ToolbarWrapper = styled.div`
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const LoadingMask = styled.div`
|
||||
position: absolute;
|
||||
inset: 35px 0 0 0; /* 避开 toolbar 高度 */
|
||||
display: flex;
|
||||
flex-direction: column; /* 垂直堆叠 */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-background);
|
||||
z-index: 4;
|
||||
gap: 12px;
|
||||
`
|
||||
|
||||
export default MinAppPage
|
||||
|
||||
@ -5,6 +5,24 @@ const logger = loggerService.withContext('WebviewStateManager')
|
||||
// Global WebView loaded states - shared between popup and tab modes
|
||||
const globalWebviewStates = new Map<string, boolean>()
|
||||
|
||||
// Per-app listeners (fine grained)
|
||||
type WebviewStateListener = (loaded: boolean) => void
|
||||
const appListeners = new Map<string, Set<WebviewStateListener>>()
|
||||
|
||||
const emitState = (appId: string, loaded: boolean) => {
|
||||
const listeners = appListeners.get(appId)
|
||||
if (listeners && listeners.size) {
|
||||
listeners.forEach((cb) => {
|
||||
try {
|
||||
cb(loaded)
|
||||
} catch (e) {
|
||||
// Swallow listener errors to avoid breaking others
|
||||
logger.debug(`Listener error for ${appId}: ${(e as Error).message}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set WebView loaded state for a specific app
|
||||
* @param appId - The mini-app ID
|
||||
@ -13,6 +31,7 @@ const globalWebviewStates = new Map<string, boolean>()
|
||||
export const setWebviewLoaded = (appId: string, loaded: boolean) => {
|
||||
globalWebviewStates.set(appId, loaded)
|
||||
logger.debug(`WebView state set for ${appId}: ${loaded}`)
|
||||
emitState(appId, loaded)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -33,6 +52,8 @@ export const clearWebviewState = (appId: string) => {
|
||||
if (wasLoaded) {
|
||||
logger.debug(`WebView state cleared for ${appId}`)
|
||||
}
|
||||
// 清掉监听(避免潜在内存泄漏)
|
||||
appListeners.delete(appId)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -42,6 +63,7 @@ export const clearAllWebviewStates = () => {
|
||||
const count = globalWebviewStates.size
|
||||
globalWebviewStates.clear()
|
||||
logger.debug(`Cleared all WebView states (${count} apps)`)
|
||||
appListeners.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -53,3 +75,46 @@ export const getLoadedAppIds = (): string[] => {
|
||||
.filter(([, loaded]) => loaded)
|
||||
.map(([appId]) => appId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a specific app's webview loaded state changes.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
export const onWebviewStateChange = (appId: string, listener: WebviewStateListener): (() => void) => {
|
||||
let listeners = appListeners.get(appId)
|
||||
if (!listeners) {
|
||||
listeners = new Set<WebviewStateListener>()
|
||||
appListeners.set(appId, listeners)
|
||||
}
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners!.delete(listener)
|
||||
if (listeners!.size === 0) appListeners.delete(appId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise helper: wait until the webview becomes loaded.
|
||||
* Optional timeout (ms) to avoid hanging forever; resolves false on timeout.
|
||||
*/
|
||||
export const waitForWebviewLoaded = (appId: string, timeout = 15000): Promise<boolean> => {
|
||||
if (getWebviewLoaded(appId)) return Promise.resolve(true)
|
||||
return new Promise((resolve) => {
|
||||
let done = false
|
||||
const unsubscribe = onWebviewStateChange(appId, (loaded) => {
|
||||
if (!loaded) return
|
||||
if (done) return
|
||||
done = true
|
||||
unsubscribe()
|
||||
resolve(true)
|
||||
})
|
||||
if (timeout > 0) {
|
||||
setTimeout(() => {
|
||||
if (done) return
|
||||
done = true
|
||||
unsubscribe()
|
||||
resolve(false)
|
||||
}, timeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user