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/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/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 被注销导致小程序窗口问题 */}
+
-
+
diff --git a/src/stackflow/activities/tabs/EcosystemTab.tsx b/src/stackflow/activities/tabs/EcosystemTab.tsx
index e7ef92acb..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 } from '@/services/miniapp-runtime';
+import { launchApp, useMiniappVisibilityRestore } from '@/services/miniapp-runtime';
import { ecosystemActions } from '@/stores/ecosystem';
export function EcosystemTab() {
@@ -37,6 +37,9 @@ export function EcosystemTab() {
return () => unsubscribe();
}, []);
+ // 当 Activity 从 hidden 变为 visible 时,恢复小程序可见性
+ useMiniappVisibilityRestore();
+
// App 操作
const handleAppDetail = useCallback(
(app: MiniappManifest) => {