From 7217a7216e55e7761662e2ea8cc3b82313bd3792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Tue, 9 Sep 2025 14:03:18 +0800 Subject: [PATCH] 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 --- .../src/components/MinApp/MinAppTabsPool.tsx | 143 ++++++++++++++++ .../components/MinApp/WebviewContainer.tsx | 1 + .../src/components/Tab/TabContainer.tsx | 8 +- src/renderer/src/pages/minapps/MinAppPage.tsx | 160 +++++++++++++++--- src/renderer/src/utils/webviewStateManager.ts | 65 +++++++ 5 files changed, 353 insertions(+), 24 deletions(-) create mode 100644 src/renderer/src/components/MinApp/MinAppTabsPool.tsx diff --git a/src/renderer/src/components/MinApp/MinAppTabsPool.tsx b/src/renderer/src/components/MinApp/MinAppTabsPool.tsx new file mode 100644 index 0000000000..af2c255f5f --- /dev/null +++ b/src/renderer/src/components/MinApp/MinAppTabsPool.tsx @@ -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 小程序对应的 不被卸载,只通过 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>(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', '', ...] + 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 ( + + {apps.map((app) => ( + + + + ))} + + ) +} + +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 diff --git a/src/renderer/src/components/MinApp/WebviewContainer.tsx b/src/renderer/src/components/MinApp/WebviewContainer.tsx index dd8b3c412b..dcae087f6d 100644 --- a/src/renderer/src/components/MinApp/WebviewContainer.tsx +++ b/src/renderer/src/components/MinApp/WebviewContainer.tsx @@ -115,6 +115,7 @@ const WebviewContainer = memo( = ({ children }) => { - {children} + + {/* MiniApp WebView 池(Tab 模式保活) */} + + {children} + ) } @@ -469,6 +474,7 @@ const TabContent = styled.div` margin-top: 0; border-radius: 8px; overflow: hidden; + position: relative; /* 约束 MinAppTabsPool 绝对定位范围 */ ` export default TabsContainer diff --git a/src/renderer/src/pages/minapps/MinAppPage.tsx b/src/renderer/src/pages/minapps/MinAppPage.tsx index f56b60c761..9629b3c56d 100644 --- a/src/renderer/src/pages/minapps/MinAppPage.tsx +++ b/src/renderer/src/pages/minapps/MinAppPage.tsx @@ -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(null) + const [isReady, setIsReady] = useState(() => (app ? getWebviewLoaded(app.id) : false)) + const [currentUrl, setCurrentUrl] = useState(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 ( - - - + + + + + {!isReady && ( + + + + + )} + ) } - -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 diff --git a/src/renderer/src/utils/webviewStateManager.ts b/src/renderer/src/utils/webviewStateManager.ts index 2512ef5f62..d93cec1197 100644 --- a/src/renderer/src/utils/webviewStateManager.ts +++ b/src/renderer/src/utils/webviewStateManager.ts @@ -5,6 +5,24 @@ const logger = loggerService.withContext('WebviewStateManager') // Global WebView loaded states - shared between popup and tab modes const globalWebviewStates = new Map() +// Per-app listeners (fine grained) +type WebviewStateListener = (loaded: boolean) => void +const appListeners = new Map>() + +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() 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() + 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 => { + 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) + } + }) +}