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:
George·Dong 2025-09-09 14:03:18 +08:00 committed by kangfenmao
parent d35998bd74
commit 7217a7216e
5 changed files with 353 additions and 24 deletions

View 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

View File

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

View File

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

View File

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

View File

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