From 613da1b76a7792e6f1d522c9f7323a800de8881d Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 12:06:17 +0800 Subject: [PATCH 1/3] fix(miniapps): restore miniapp visibility after tab switch When switching bottom tabs away from ecosystem and back, the miniapp window became invisible (opacity 0) because: 1. EcosystemTab uses React 19 which unmounts/pauses the child tree 2. MiniappWindowStack slots get unregistered when unmounted 3. But runtime app state remained 'active' with no re-activation on return Fix: Call activateApp() when EcosystemTab remounts to restore the active miniapp's visibility state. Adds E2E regression test for tab-switch visibility. --- e2e/ecosystem-miniapp.mock.spec.ts | 42 +++++++++++++++++++ .../activities/tabs/EcosystemTab.tsx | 10 ++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/e2e/ecosystem-miniapp.mock.spec.ts b/e2e/ecosystem-miniapp.mock.spec.ts index d7dc006a2..e4a126120 100644 --- a/e2e/ecosystem-miniapp.mock.spec.ts +++ b/e2e/ecosystem-miniapp.mock.spec.ts @@ -536,6 +536,48 @@ test.describe('小程序权限集成测试', () => { await injectTestData(page) }) + test('底部 Tab 切换后返回生态,小程序仍可见', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem( + 'ecosystem_my_apps', + JSON.stringify([{ appId: 'xin.dweb.teleport', installedAt: Date.now() - 3600000, lastUsedAt: Date.now() - 1800000 }]) + ) + }) + + await page.goto('/#/') + await page.waitForLoadState('networkidle') + await page.waitForTimeout(500) + + // 进入生态 - 我的,并打开 Teleport + await page.getByTestId('tab-ecosystem').click() + await page.waitForTimeout(300) + await swipeToMyAppsPage(page) + await page.waitForTimeout(300) + + await page.locator('[data-testid="ios-app-icon-xin.dweb.teleport"]').click({ button: 'right' }) + await page.waitForTimeout(200) + await page.locator('button:has-text("打开")').click() + + const teleportFrame = page.frameLocator('iframe[data-app-id="xin.dweb.teleport"]') + await teleportFrame.getByRole('button', { name: '启动传送门' }).waitFor({ state: 'visible', timeout: 15000 }) + + // 切走到底部其他 tab,再切回来 + await page.getByTestId('tab-wallet').click() + await page.waitForTimeout(300) + await page.getByTestId('tab-ecosystem').click() + await page.waitForTimeout(500) + + // 生态页重新挂载后需要再次滑到"我的"页 + await swipeToMyAppsPage(page) + await page.waitForTimeout(300) + + // Teleport iframe/窗口应重新可见 + await expect(page.locator('[data-testid="miniapp-window"][data-app-id="xin.dweb.teleport"]')).toBeVisible({ + timeout: 15000, + }) + await expect(teleportFrame.getByRole('button', { name: '启动传送门' })).toBeVisible({ timeout: 15000 }) + }) + test('Teleport 启动传送门应弹出权限请求', async ({ page }) => { await page.addInitScript(() => { localStorage.setItem( diff --git a/src/stackflow/activities/tabs/EcosystemTab.tsx b/src/stackflow/activities/tabs/EcosystemTab.tsx index e7ef92acb..f531ae000 100644 --- a/src/stackflow/activities/tabs/EcosystemTab.tsx +++ b/src/stackflow/activities/tabs/EcosystemTab.tsx @@ -14,7 +14,7 @@ import { import type { MiniappManifest } from '@/services/ecosystem'; import { EcosystemDesktop, type EcosystemDesktopHandle } from '@/components/ecosystem'; import { computeFeaturedScore } from '@/services/ecosystem/scoring'; -import { launchApp } from '@/services/miniapp-runtime'; +import { launchApp, activateApp, miniappRuntimeStore } from '@/services/miniapp-runtime'; import { ecosystemActions } from '@/stores/ecosystem'; export function EcosystemTab() { @@ -37,6 +37,14 @@ export function EcosystemTab() { return () => unsubscribe(); }, []); + // 当生态 Tab 重新挂载时,恢复当前活跃的小程序 + useEffect(() => { + const { activeAppId } = miniappRuntimeStore.state; + if (activeAppId) { + activateApp(activeAppId); + } + }, []); + // App 操作 const handleAppDetail = useCallback( (app: MiniappManifest) => { From 4526bea86d8884e10809e165f824dbb47bd61de3 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 13:52:02 +0800 Subject: [PATCH 2/3] feat(miniapps): add slot lifecycle for tab switch visibility Add slot status management in runtime-refs.ts: - slotStatusMap tracks slot ready/lost state - subscribeSlotStatus() for React subscription - getSlotStatus() for querying Add useSlotStatus hook using useSyncExternalStore MiniappWindowPortal now: - Subscribes to slot status - Uses fallback container when slot lost - Disables layoutId when slot lost to prevent animation issues - Sets visibility:hidden when slot lost This fixes miniapp becoming invisible after tab switch. --- src/StackflowApp.tsx | 2 + .../ecosystem/miniapp-window.stories.tsx | 167 +++++++++++++++++- src/components/ecosystem/miniapp-window.tsx | 29 ++- src/services/miniapp-runtime/hooks.ts | 72 ++++++++ src/services/miniapp-runtime/index.ts | 5 + src/services/miniapp-runtime/runtime-refs.ts | 41 +++++ src/services/miniapp-runtime/types.ts | 3 + .../activities/tabs/EcosystemTab.tsx | 11 +- 8 files changed, 315 insertions(+), 15 deletions(-) create mode 100644 src/services/miniapp-runtime/hooks.ts diff --git a/src/StackflowApp.tsx b/src/StackflowApp.tsx index cef3f2718..6cc936914 100644 --- a/src/StackflowApp.tsx +++ b/src/StackflowApp.tsx @@ -17,6 +17,8 @@ export function StackflowApp() { {/* 层叠视图 - 多应用管理 */} + {/* Fallback 容器 - 当 slot lost 时保持 MiniappWindow 挂载 */} +
diff --git a/src/components/ecosystem/miniapp-window.stories.tsx b/src/components/ecosystem/miniapp-window.stories.tsx index 7383a1356..8f4bb66e9 100644 --- a/src/components/ecosystem/miniapp-window.stories.tsx +++ b/src/components/ecosystem/miniapp-window.stories.tsx @@ -5,7 +5,8 @@ */ import type { Meta, StoryObj } from '@storybook/react'; -import { useRef, useEffect, useState } from 'react'; +import { useRef, useEffect, useState, Activity } from 'react'; +import { within, userEvent, expect } from '@storybook/test'; import { MiniappSplashScreen } from './miniapp-splash-screen'; import { MiniappCapsule } from './miniapp-capsule'; import { MiniappWindow } from './miniapp-window'; @@ -17,6 +18,7 @@ import { launchApp, resetMiniappVisualConfig, setMiniappMotionTimeScale, + useMiniappVisibilityRestore, } from '@/services/miniapp-runtime'; import { MiniappVisualProvider } from '@/services/miniapp-runtime/MiniappVisualProvider'; import type { MiniappManifest } from '@/services/ecosystem'; @@ -271,3 +273,166 @@ export const LaunchDemo: Story = { ); }, }; + +/** + * 模拟 EcosystemTab 组件 + * 在 Activity 内部调用 activateApp,确保 slots 已注册 + */ +function EcosystemTabMock({ + desktopRef, + myApps, + onAppOpen, +}: { + desktopRef: React.RefObject; + myApps: { app: MiniappManifest; lastUsed: number }[]; + onAppOpen: (app: MiniappManifest) => void; +}) { + // 使用与真实 EcosystemTab 相同的 hook + useMiniappVisibilityRestore(); + + return ( + console.log('Detail:', app.name)} + onAppRemove={(id) => console.log('Remove:', id)} + /> + ); +} + +/** + * Tab 切换可见性测试 + * + * 使用真实的 TabBar 和 React 19 Activity 模拟底部 Tab 切换场景: + * - MiniappWindow 在全局层渲染(不随 Tab 切换卸载) + * - EcosystemTabMock 在 Activity 内,包含 activateApp 逻辑 + * + * 测试步骤: + * 1. 点击生态 Tab,然后点击图标启动应用 + * 2. 点击钱包 Tab 切走 + * 3. 点击生态 Tab 切回 + * 4. 观察小程序窗口是否仍然可见 + */ +export const TabSwitchVisibility: Story = { + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], + render: function TabSwitchVisibilityStory() { + const desktopRef = useRef(null); + const [activeTab, setActiveTab] = useState<'wallet' | 'ecosystem' | 'settings'>('ecosystem'); + + // 清理旧状态 + useEffect(() => { + closeAllApps(); + resetMiniappVisualConfig(); + return () => { + resetMiniappVisualConfig(); + }; + }, []); + + + + const myApps = mockApps.map((app, i) => ({ + app, + lastUsed: Date.now() - i * 1000 * 60 * 60, + })); + + const handleAppOpen = (app: MiniappManifest) => { + console.log('[TabSwitchVisibility] Opening app:', app.name); + const manifest: MiniappManifest = { ...app, targetDesktop: 'mine' }; + desktopRef.current?.slideTo('mine'); + requestAnimationFrame(() => launchApp(app.id, manifest)); + }; + + const handleTabChange = (tab: 'wallet' | 'ecosystem' | 'settings') => { + console.log('[TabSwitchVisibility] Tab changed to:', tab); + setActiveTab(tab); + }; + + return ( + +
+
+ {/* 使用 React 19 Activity 模拟真实 Tab 切换行为 */} + +
+
+
👛
+
钱包页面
+
+
+
+ + + + + + +
+
+
⚙️
+
设置页面
+
+
+
+ + {/* MiniappWindow 在全局层,不随 Tab 切换卸载(与 StackflowApp 一致) */} + + {/* Fallback 容器 - 当 slot lost 时保持 MiniappWindow 挂载 */} +
+
+ + {/* 真实 TabBar */} + +
+ + ); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + await new Promise((r) => setTimeout(r, 500)); + + await step('点击第一个应用图标启动', async () => { + const appIcon = canvas.getAllByTestId(/^ios-app-icon-/)[0]; + expect(appIcon).toBeInTheDocument(); + await userEvent.click(appIcon); + await new Promise((r) => setTimeout(r, 1500)); + }); + + await step('验证窗口已渲染', async () => { + const window = canvas.getByTestId('miniapp-window'); + expect(window).toBeInTheDocument(); + }); + + await step('点击钱包 Tab 切走', async () => { + const walletTab = canvas.getByTestId('tab-wallet'); + await userEvent.click(walletTab); + await new Promise((r) => setTimeout(r, 300)); + }); + + await step('点击生态 Tab 切回', async () => { + const ecosystemTab = canvas.getByTestId('tab-ecosystem'); + await userEvent.click(ecosystemTab); + await new Promise((r) => setTimeout(r, 500)); + }); + + await step('验证窗口重新可见', async () => { + const window = canvas.getByTestId('miniapp-window'); + expect(window).toBeInTheDocument(); + }); + }, +}; diff --git a/src/components/ecosystem/miniapp-window.tsx b/src/components/ecosystem/miniapp-window.tsx index 7cf284d5a..227a4ada7 100644 --- a/src/components/ecosystem/miniapp-window.tsx +++ b/src/components/ecosystem/miniapp-window.tsx @@ -24,6 +24,7 @@ import { didDismiss, didPresent, settleFlow, + useSlotStatus, } from '@/services/miniapp-runtime'; import { getMiniappMotionPresets, type MiniappMotionPresets } from '@/services/miniapp-runtime/visual-config'; import { MiniappSplashScreen } from './miniapp-splash-screen'; @@ -114,8 +115,17 @@ function MiniappWindowPortal({ return a?.state === 'launching' || a?.state === 'splash' || a?.state === 'closing'; }); + // 订阅 slot 状态,响应式感知 slot 注册/注销 + const slotStatus = useSlotStatus(presentation.desktop, appId); const portalHost = getDesktopAppSlotRef(presentation.desktop, appId); + // 当 slot lost 时使用 fallback 容器,保持组件挂载避免 motion 动画问题 + const fallbackHost = useMemo(() => { + return document.getElementById('miniapp-fallback-portal') ?? document.body; + }, []); + const actualHost = portalHost ?? fallbackHost; + const isSlotLost = slotStatus === 'lost' || !portalHost; + useLayoutEffect(() => { if (!app) { setPresentApp(null); @@ -209,7 +219,8 @@ function MiniappWindowPortal({ requestDismissSplash(appId); }, [appId]); - if (!portalHost) return null; + // 当 slot lost 时禁用 layoutId,防止动画到错误位置 + const enableLayoutId = !isSlotLost; const node = ( ); - return createPortal(node, portalHost); + return createPortal(node, actualHost); } export default MiniappWindow; diff --git a/src/services/miniapp-runtime/hooks.ts b/src/services/miniapp-runtime/hooks.ts new file mode 100644 index 000000000..b18e02dc8 --- /dev/null +++ b/src/services/miniapp-runtime/hooks.ts @@ -0,0 +1,72 @@ +/** + * Miniapp Runtime Hooks + * + * 提供与小程序运行时生命周期相关的 React hooks。 + */ + +import { useEffect, useSyncExternalStore } from 'react' +import type { MiniappTargetDesktop } from '../ecosystem/types' +import type { SlotStatus } from './types' +import { getSlotStatus, subscribeSlotStatus } from './runtime-refs' + +// 延迟导入避免循环依赖 - 使用 import() 动态导入 +async function loadMiniappRuntime() { + return import('./index') +} + +/** + * 当组件挂载/渲染时,恢复当前活跃的小程序可见性。 + * + * 使用场景: + * - 放在 Activity 内部的组件中(如 EcosystemTab) + * - 当 Activity 从 hidden 变为 visible 时,确保小程序窗口重新显示 + * + * 原理: + * - React 19 Activity mode="hidden" 可能暂停组件而非卸载 + * - MiniappWindowStack 的 slots 在 Activity 内部注册 + * - MiniappWindowPortal 需要有效的 portalHost 才能渲染 + * - 此 hook 在组件渲染后调用 activateApp(),触发 store 更新 + * - 确保 slots 已注册后再触发 MiniappWindowPortal 重新渲染 + * + * @example + * ```tsx + * function EcosystemTab() { + * useMiniappVisibilityRestore(); + * return ; + * } + * ``` + */ +export function useMiniappVisibilityRestore(): void { + useEffect(() => { + loadMiniappRuntime().then(({ miniappRuntimeStore, activateApp }) => { + const { activeAppId } = miniappRuntimeStore.state + if (activeAppId) { + activateApp(activeAppId) + } + }) + }) +} + +/** + * 订阅指定 slot 的状态 + * + * @param desktop - 目标桌面(mine 或 stack) + * @param appId - 应用 ID + * @returns slot 状态:'ready'(已注册)、'lost'(已注销)或 null(从未注册) + * + * @example + * ```tsx + * function MiniappWindowPortal({ appId, presentation }) { + * const slotStatus = useSlotStatus(presentation.desktop, appId); + * const isSlotLost = slotStatus === 'lost'; + * // ... + * } + * ``` + */ +export function useSlotStatus(desktop: MiniappTargetDesktop, appId: string): SlotStatus | null { + return useSyncExternalStore( + subscribeSlotStatus, + () => getSlotStatus(desktop, appId), + () => getSlotStatus(desktop, appId) + ) +} diff --git a/src/services/miniapp-runtime/index.ts b/src/services/miniapp-runtime/index.ts index 1f043cc44..061e8dabe 100644 --- a/src/services/miniapp-runtime/index.ts +++ b/src/services/miniapp-runtime/index.ts @@ -91,7 +91,10 @@ export { unregisterStackContainerRef, unregisterSplashBgRef, unregisterSplashIconRef, + getSlotStatus, + subscribeSlotStatus, } from './runtime-refs' +export { useMiniappVisibilityRestore, useSlotStatus } from './hooks' // 动画实现已迁移到 motion/layoutId;旧版 FLIP/Popover 动画模块保留为备份文件(*.bak) @@ -122,6 +125,8 @@ function attachBioProviderToIframe(appId: string, iframe: HTMLIFrameElement, man /** Store 实例 */ export const miniappRuntimeStore = new Store(initialState) + + export function setMiniappVisualConfig(update: MiniappVisualConfigUpdate): void { miniappRuntimeStore.setState((s) => ({ ...s, diff --git a/src/services/miniapp-runtime/runtime-refs.ts b/src/services/miniapp-runtime/runtime-refs.ts index e0d5bf531..dc3b26ea1 100644 --- a/src/services/miniapp-runtime/runtime-refs.ts +++ b/src/services/miniapp-runtime/runtime-refs.ts @@ -9,6 +9,39 @@ */ import type { MiniappTargetDesktop } from '../ecosystem/types' +import type { SlotStatus } from './types' + +// ============================================ +// Slot 状态管理 +// ============================================ + +/** Slot 状态 Map(key: `${desktop}:${appId}`) */ +const slotStatusMap = new Map() + +/** Slot 状态变化监听器 */ +const slotStatusListeners = new Set<() => void>() + +/** + * 获取 slot 状态 + */ +export function getSlotStatus(desktop: MiniappTargetDesktop, appId: string): SlotStatus | null { + const key = `${desktop}:${appId}` + return slotStatusMap.get(key) ?? null +} + +/** + * 订阅 slot 状态变化 + * @returns 取消订阅函数 + */ +export function subscribeSlotStatus(listener: () => void): () => void { + slotStatusListeners.add(listener) + return () => slotStatusListeners.delete(listener) +} + +/** 通知所有监听器 */ +function notifySlotStatusChange(): void { + slotStatusListeners.forEach((listener) => listener()) +} /** 图标 container ref(popover-container) */ const iconRefs = new Map() @@ -188,6 +221,10 @@ export function registerDesktopAppSlotRef(targetDesktop: MiniappTargetDesktop, a const byApp = desktopAppSlotRefs.get(targetDesktop) ?? new Map() byApp.set(appId, element) desktopAppSlotRefs.set(targetDesktop, byApp) + // 更新 slot 状态并通知监听器 + const key = `${targetDesktop}:${appId}` + slotStatusMap.set(key, 'ready') + notifySlotStatusChange() } export function unregisterDesktopAppSlotRef(targetDesktop: MiniappTargetDesktop, appId: string): void { @@ -197,6 +234,10 @@ export function unregisterDesktopAppSlotRef(targetDesktop: MiniappTargetDesktop, if (byApp.size === 0) { desktopAppSlotRefs.delete(targetDesktop) } + // 更新 slot 状态并通知监听器 + const key = `${targetDesktop}:${appId}` + slotStatusMap.set(key, 'lost') + notifySlotStatusChange() } export function getDesktopAppSlotRef(targetDesktop: MiniappTargetDesktop, appId: string): HTMLElement | null { diff --git a/src/services/miniapp-runtime/types.ts b/src/services/miniapp-runtime/types.ts index 81a3a421b..2bf97023d 100644 --- a/src/services/miniapp-runtime/types.ts +++ b/src/services/miniapp-runtime/types.ts @@ -152,6 +152,9 @@ export type MiniappRuntimeEvent = | { type: 'stack-view:open' } | { type: 'stack-view:close' } +/** Slot 状态 */ +export type SlotStatus = 'ready' | 'lost' + /** 运行时事件监听器 */ export type MiniappRuntimeListener = (event: MiniappRuntimeEvent) => void diff --git a/src/stackflow/activities/tabs/EcosystemTab.tsx b/src/stackflow/activities/tabs/EcosystemTab.tsx index f531ae000..2d614436d 100644 --- a/src/stackflow/activities/tabs/EcosystemTab.tsx +++ b/src/stackflow/activities/tabs/EcosystemTab.tsx @@ -14,7 +14,7 @@ import { import type { MiniappManifest } from '@/services/ecosystem'; import { EcosystemDesktop, type EcosystemDesktopHandle } from '@/components/ecosystem'; import { computeFeaturedScore } from '@/services/ecosystem/scoring'; -import { launchApp, activateApp, miniappRuntimeStore } from '@/services/miniapp-runtime'; +import { launchApp, useMiniappVisibilityRestore } from '@/services/miniapp-runtime'; import { ecosystemActions } from '@/stores/ecosystem'; export function EcosystemTab() { @@ -37,13 +37,8 @@ export function EcosystemTab() { return () => unsubscribe(); }, []); - // 当生态 Tab 重新挂载时,恢复当前活跃的小程序 - useEffect(() => { - const { activeAppId } = miniappRuntimeStore.state; - if (activeAppId) { - activateApp(activeAppId); - } - }, []); + // 当 Activity 从 hidden 变为 visible 时,恢复小程序可见性 + useMiniappVisibilityRestore(); // App 操作 const handleAppDetail = useCallback( From ae21444665e7086b41c9fe77f544db60582b7d03 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 13:59:20 +0800 Subject: [PATCH 3/3] fix(miniapps): use display:none for EcosystemTab instead of Activity Replace Activity mode='hidden' with display:none for EcosystemTab to prevent MiniappWindowStack slots from being unregistered during tab switch. This ensures miniapp windows remain visible after switching tabs away from ecosystem and back. Activity mode='hidden' may pause/unmount components causing slot unregistration, while display:none keeps the component fully mounted. --- src/stackflow/activities/MainTabsActivity.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/stackflow/activities/MainTabsActivity.tsx b/src/stackflow/activities/MainTabsActivity.tsx index 4e66383c6..a60dbd5c2 100644 --- a/src/stackflow/activities/MainTabsActivity.tsx +++ b/src/stackflow/activities/MainTabsActivity.tsx @@ -200,9 +200,10 @@ export const MainTabsActivity: ActivityComponentType = ({ params - + {/* EcosystemTab 不使用 Activity 包裹,避免 slot 被注销导致小程序窗口问题 */} +
- +